Merge remote-tracking branch 'origin/master' into add_keep_events_for_to_agents

Conflicts:
CHANGES.md
app/models/agent.rb

Andrew Cantino 10 years ago
parent
commit
580ce8a083
64 changed files with 1374 additions and 757 deletions
  1. 3 2
      CHANGES.md
  2. 3 3
      Gemfile.lock
  3. 4 2
      README.md
  4. 1 1
      VERSION
  5. 1 0
      app/assets/javascripts/application.js.coffee.erb
  6. 4 0
      app/assets/stylesheets/application.css.scss.erb
  7. 3 3
      app/concerns/email_concern.rb
  8. 8 8
      app/concerns/twitter_concern.rb
  9. 6 6
      app/concerns/weibo_concern.rb
  10. 1 1
      app/controllers/events_controller.rb
  11. 1 1
      app/controllers/logs_controller.rb
  12. 30 8
      app/models/agent.rb
  13. 3 0
      app/models/agent_log.rb
  14. 13 13
      app/models/agents/adioso_agent.rb
  15. 14 14
      app/models/agents/digest_email_agent.rb
  16. 4 4
      app/models/agents/email_agent.rb
  17. 22 22
      app/models/agents/event_formatting_agent.rb
  18. 262 109
      app/models/agents/human_task_agent.rb
  19. 2 2
      app/models/agents/manual_event_agent.rb
  20. 27 29
      app/models/agents/peak_detector_agent.rb
  21. 4 4
      app/models/agents/post_agent.rb
  22. 10 10
      app/models/agents/sentiment_agent.rb
  23. 15 15
      app/models/agents/translation_agent.rb
  24. 24 24
      app/models/agents/trigger_agent.rb
  25. 25 25
      app/models/agents/twilio_agent.rb
  26. 20 20
      app/models/agents/twitter_publish_agent.rb
  27. 23 23
      app/models/agents/twitter_stream_agent.rb
  28. 12 12
      app/models/agents/twitter_user_agent.rb
  29. 3 3
      app/models/agents/user_location_agent.rb
  30. 9 9
      app/models/agents/weather_agent.rb
  31. 62 0
      app/models/agents/webhook_agent.rb
  32. 39 39
      app/models/agents/website_agent.rb
  33. 19 19
      app/models/agents/weibo_publish_agent.rb
  34. 11 11
      app/models/agents/weibo_user_agent.rb
  35. 6 8
      app/models/event.rb
  36. 1 1
      app/views/agents/show.html.erb
  37. 1 1
      app/views/layouts/application.html.erb
  38. 1 1
      config/initializers/delayed_job.rb
  39. 0 7
      config/initializers/recursively_symbolize_keys.rb
  40. 67 0
      db/migrate/20131223032112_switch_to_json_serialization.rb
  41. 14 0
      db/migrate/20131227000021_add_cached_dates_to_agent.rb
  42. 1 1
      doc/deployment/backup/example_backup.rb
  43. 38 2
      lib/capistrano/sync.rb
  44. 42 0
      lib/json_serialized_field.rb
  45. 9 0
      lib/json_with_indifferent_access.rb
  46. 0 43
      lib/serialize_and_symbolize.rb
  47. 0 11
      lib/utils.rb
  48. 2 2
      spec/controllers/agents_controller_spec.rb
  49. 3 1
      spec/controllers/logs_controller_spec.rb
  50. 3 3
      spec/controllers/user_location_updates_controller_spec.rb
  51. 2 2
      spec/controllers/webhooks_controller_spec.rb
  52. 7 7
      spec/fixtures/agents.yml
  53. 2 2
      spec/fixtures/events.yml
  54. 8 0
      spec/models/agent_log_spec.rb
  55. 70 2
      spec/models/agent_spec.rb
  56. 5 5
      spec/models/agents/digest_email_agent_spec.rb
  57. 4 4
      spec/models/agents/email_agent_spec.rb
  58. 270 117
      spec/models/agents/human_task_agent_spec.rb
  59. 29 29
      spec/models/agents/peak_detector_agent_spec.rb
  60. 1 1
      spec/models/agents/sentiment_agent_spec.rb
  61. 53 53
      spec/models/agents/trigger_agent_spec.rb
  62. 7 7
      spec/models/agents/twitter_stream_agent_spec.rb
  63. 31 0
      spec/models/agents/webhook_agent_spec.rb
  64. 9 5
      spec/models/agents/website_agent_spec.rb

+ 3 - 2
CHANGES.md

@@ -1,9 +1,10 @@
1 1
 # Changes
2 2
 
3
-* 0.3 (Dec 22, 2013) - Agents now have an optional keep_events_for option that is propagated to created events' expires_at field for eventual cleanup.
3
+* 0.31 (Jan 2, 2014)   - Agents now have an optional keep\_events\_for option that is propagated to created events' expires\_at field, and they update their events' expires\_at fields on change.
4
+* 0.3 (Jan 1, 2014)    - Remove symbolization of memory, options, and payloads; convert memory, options, and payloads to JSON from YAML.  Migration will perform conversion and adjust tables to be UTF-8.  Recommend making a DB backup before migrating.
4 5
 * 0.2 (Nov 6, 2013)    - PeakDetectorAgent now uses `window_duration_in_days` and `min_peak_spacing_in_days`.  Additionally, peaks trigger when the time series rises over the standard deviation multiple, not after it starts to fall.
5 6
 * June 29, 2013        - Removed rails\_admin because it was causing deployment issues. Better to have people install their favorite admin tool if they want one.
6 7
 * June, 2013           - A number of new agents have been contributed, including interfaces to Weibo, Twitter, and Twilio, as well as Agents for translation, sentiment analysis, and for posting and receiving webhooks.
7
-* March 24, 2013 (0.1) - Refactored loading of Agents for `check` and `receive` to use ids instead of full objects.  This should fix the too-large delayed_job issues.  Added `system_timer` and `fastercsv` to the Gemfile for the Ruby 1.8 platform.
8
+* March 24, 2013 (0.1) - Refactored loading of Agents for `check` and `receive` to use ids instead of full objects.  This should fix the too-large delayed\_job issues.  Added `system_timer` and `fastercsv` to the Gemfile for the Ruby 1.8 platform.
8 9
 * March 18, 2013       - Added Wiki page about the [Agent API](https://github.com/cantino/huginn/wiki/Creating-a-new-agent).
9 10
 * March 17, 2013       - Switched to JSONPath for defining paths through JSON structures.  The WebsiteAgent can now scrape and parse JSON.

+ 3 - 3
Gemfile.lock

@@ -63,9 +63,9 @@ GEM
63 63
       railties (>= 3.2.6, < 5)
64 64
       warden (~> 1.2.3)
65 65
     diff-lcs (1.2.4)
66
-    dotenv (0.8.0)
67
-    dotenv-rails (0.8.0)
68
-      dotenv (= 0.8.0)
66
+    dotenv (0.9.0)
67
+    dotenv-rails (0.9.0)
68
+      dotenv (= 0.9.0)
69 69
     em-http-request (1.0.3)
70 70
       addressable (>= 2.2.3)
71 71
       cookiejar

+ 4 - 2
README.md

@@ -13,7 +13,10 @@ Huginn is a system for building agents that perform automated tasks for you onli
13 13
 * Track the weather and get an email when it's going to rain (or snow) tomorrow
14 14
 * Follow your project names on Twitter and get updates when people mention them
15 15
 * Scrape websites and receive emails when they change
16
+* Compose digest emails about things you care about to be sent at specific times of the day
17
+* Track counts of high frequency events and SMS on changes, such as the term "san francisco emergency"
16 18
 * Track your location over time
19
+* Create Amazon Mechanical Turk tasks as the input, or output, of events
17 20
 
18 21
 Follow [@tectonic](https://twitter.com/tectonic) for updates as Huginn evolves, and join us in \#huginn on Freenode IRC to discuss the project.
19 22
 
@@ -84,7 +87,7 @@ In order to use the WeatherAgent you need an [API key with Wunderground](http://
84 87
 
85 88
 You can use [Post Location](https://github.com/cantino/post_location) on your iPhone to post your location to an instance of the UserLocationAgent.  Make a new one to see instructions.
86 89
 
87
-#### Enable DelayedJobWeb for handy delayed_job monitoring and control
90
+#### Enable DelayedJobWeb for handy delayed\_job monitoring and control
88 91
 
89 92
 * Edit `config.ru`, uncomment the DelayedJobWeb section, and change the DelayedJobWeb username and password.
90 93
 * Uncomment `match "/delayed_job" => DelayedJobWeb, :anchor => false` in `config/routes.rb`.
@@ -110,6 +113,5 @@ Please fork, add specs, and send pull requests!
110 113
 
111 114
 [![Build Status](https://travis-ci.org/cantino/huginn.png)](https://travis-ci.org/cantino/huginn) [![Code Climate](https://codeclimate.com/github/cantino/huginn.png)](https://codeclimate.com/github/cantino/huginn)
112 115
 
113
-
114 116
 [![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/cantino/huginn/trend.png)](https://bitdeli.com/free "Bitdeli Badge")
115 117
 

+ 1 - 1
VERSION

@@ -1 +1 @@
1
-0.2
1
+0.3

+ 1 - 0
app/assets/javascripts/application.js.coffee.erb

@@ -108,6 +108,7 @@ $(document).ready ->
108 108
       $("#logs .refresh, #logs .clear").hide()
109 109
       $.post "/agents/#{agentId}/logs/clear", { "_method": "DELETE" }, (html) =>
110 110
         $("#logs .logs").html html
111
+        $("#show-tabs li a.recent-errors").removeClass 'recent-errors'
111 112
         $("#logs .spinner").stop(true, true).fadeOut ->
112 113
           $("#logs .refresh, #logs .clear").show()
113 114
 

+ 4 - 0
app/assets/stylesheets/application.css.scss.erb

@@ -122,3 +122,7 @@ span.not-applicable:after {
122 122
     margin: 0 10px;
123 123
   }
124 124
 }
125
+
126
+#show-tabs li a.recent-errors {
127
+  font-weight: bold;
128
+}

+ 3 - 3
app/concerns/email_concern.rb

@@ -1,18 +1,18 @@
1 1
 module EmailConcern
2 2
   extend ActiveSupport::Concern
3 3
 
4
-  MAIN_KEYS = %w[title message text main value].map(&:to_sym)
4
+  MAIN_KEYS = %w[title message text main value]
5 5
 
6 6
   included do
7 7
     self.validate :validate_email_options
8 8
   end
9 9
 
10 10
   def validate_email_options
11
-    errors.add(:base, "subject and expected_receive_period_in_days are required") unless options[:subject].present? && options[:expected_receive_period_in_days].present?
11
+    errors.add(:base, "subject and expected_receive_period_in_days are required") unless options['subject'].present? && options['expected_receive_period_in_days'].present?
12 12
   end
13 13
 
14 14
   def working?
15
-    last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
15
+    last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
16 16
   end
17 17
 
18 18
   def present(payload)

+ 8 - 8
app/concerns/twitter_concern.rb

@@ -7,20 +7,20 @@ module TwitterConcern
7 7
   end
8 8
 
9 9
   def validate_twitter_options
10
-    unless options[:consumer_key].present? &&
11
-      options[:consumer_secret].present? &&
12
-      options[:oauth_token].present? &&
13
-      options[:oauth_token_secret].present?
10
+    unless options['consumer_key'].present? &&
11
+      options['consumer_secret'].present? &&
12
+      options['oauth_token'].present? &&
13
+      options['oauth_token_secret'].present?
14 14
       errors.add(:base, "consumer_key, consumer_secret, oauth_token and oauth_token_secret are required to authenticate with the Twitter API")
15 15
     end
16 16
   end
17 17
 
18 18
   def configure_twitter
19 19
     Twitter.configure do |config|
20
-      config.consumer_key = options[:consumer_key]
21
-      config.consumer_secret = options[:consumer_secret]
22
-      config.oauth_token = options[:oauth_token] || options[:access_key]
23
-      config.oauth_token_secret = options[:oauth_token_secret] || options[:access_secret]
20
+      config.consumer_key = options['consumer_key']
21
+      config.consumer_secret = options['consumer_secret']
22
+      config.oauth_token = options['oauth_token'] || options['access_key']
23
+      config.oauth_token_secret = options['oauth_token_secret'] || options['access_secret']
24 24
     end
25 25
   end
26 26
 

+ 6 - 6
app/concerns/weibo_concern.rb

@@ -6,19 +6,19 @@ module WeiboConcern
6 6
   end
7 7
 
8 8
   def validate_weibo_options
9
-    unless options[:app_key].present? &&
10
-        options[:app_secret].present? &&
11
-        options[:access_token].present?
9
+    unless options['app_key'].present? &&
10
+        options['app_secret'].present? &&
11
+        options['access_token'].present?
12 12
         errors.add(:base, "app_key, app_secret and access_token are required")
13 13
     end
14 14
   end
15 15
 
16 16
   def weibo_client
17 17
     unless @weibo_client
18
-      WeiboOAuth2::Config.api_key = options[:app_key] # WEIBO_APP_KEY
19
-      WeiboOAuth2::Config.api_secret = options[:app_secret] # WEIBO_APP_SECRET
18
+      WeiboOAuth2::Config.api_key = options['app_key'] # WEIBO_APP_KEY
19
+      WeiboOAuth2::Config.api_secret = options['app_secret'] # WEIBO_APP_SECRET
20 20
       @weibo_client = WeiboOAuth2::Client.new
21
-      @weibo_client.get_token_from_hash :access_token => options[:access_token]
21
+      @weibo_client.get_token_from_hash :access_token => options['access_token']
22 22
     end
23 23
     @weibo_client
24 24
   end

+ 1 - 1
app/controllers/events_controller.rb

@@ -6,7 +6,7 @@ class EventsController < ApplicationController
6 6
       @agent = current_user.agents.find(params[:agent])
7 7
       @events = @agent.events.page(params[:page])
8 8
     else
9
-      @events = current_user.events.page(params[:page])
9
+      @events = current_user.events.preload(:agent).page(params[:page])
10 10
     end
11 11
 
12 12
     respond_to do |format|

+ 1 - 1
app/controllers/logs_controller.rb

@@ -7,7 +7,7 @@ class LogsController < ApplicationController
7 7
   end
8 8
 
9 9
   def clear
10
-    @agent.logs.delete_all
10
+    @agent.delete_logs!
11 11
     index
12 12
   end
13 13
 

+ 30 - 8
app/models/agent.rb

@@ -1,14 +1,13 @@
1
-require 'serialize_and_symbolize'
1
+require 'json_serialized_field'
2 2
 require 'assignable_types'
3 3
 require 'markdown_class_attributes'
4 4
 require 'utils'
5 5
 
6 6
 class Agent < ActiveRecord::Base
7
-  include SerializeAndSymbolize
8 7
   include AssignableTypes
9 8
   include MarkdownClassAttributes
9
+  include JSONSerializedField
10 10
 
11
-  serialize_and_symbolize :options, :memory
12 11
   markdown_class_attributes :description, :event_description
13 12
 
14 13
   load_types_in "Agents"
@@ -20,10 +19,13 @@ class Agent < ActiveRecord::Base
20 19
 
21 20
   attr_accessible :options, :memory, :name, :type, :schedule, :source_ids, :keep_events_for
22 21
 
22
+  json_serialize :options, :memory
23
+
23 24
   validates_presence_of :name, :user
24 25
   validates_inclusion_of :keep_events_for, :in => EVENT_RETENTION_SCHEDULES.map(&:last)
25 26
   validate :sources_are_owned
26 27
   validate :validate_schedule
28
+  validate :validate_options
27 29
 
28 30
   after_initialize :set_default_schedule
29 31
   before_validation :set_default_schedule
@@ -36,7 +38,6 @@ class Agent < ActiveRecord::Base
36 38
   has_many :events, :dependent => :delete_all, :inverse_of => :agent, :order => "events.id desc"
37 39
   has_one  :most_recent_event, :inverse_of => :agent, :class_name => "Event", :order => "events.id desc"
38 40
   has_many :logs, :dependent => :delete_all, :inverse_of => :agent, :class_name => "AgentLog", :order => "agent_logs.id desc"
39
-  has_one  :most_recent_log, :inverse_of => :agent, :class_name => "AgentLog", :order => "agent_logs.id desc"
40 41
   has_many :received_events, :through => :sources, :class_name => "Event", :source => :events, :order => "events.id desc"
41 42
   has_many :links_as_source, :dependent => :delete_all, :foreign_key => "source_id", :class_name => "Link", :inverse_of => :source
42 43
   has_many :links_as_receiver, :dependent => :delete_all, :foreign_key => "receiver_id", :class_name => "Link", :inverse_of => :receiver
@@ -78,13 +79,16 @@ class Agent < ActiveRecord::Base
78 79
     raise "Implement me in your subclass"
79 80
   end
80 81
 
81
-  def event_created_within(days)
82
-    event = most_recent_event
83
-    event && event.created_at > days.to_i.days.ago && event.payload.present? && event
82
+  def validate_options
83
+    # Implement me in your subclass to test for valid options.
84
+  end
85
+
86
+  def event_created_within?(days)
87
+    last_event_at && last_event_at > days.to_i.days.ago
84 88
   end
85 89
 
86 90
   def recent_error_logs?
87
-    most_recent_log.try(:level) == 4
91
+    last_event_at && last_error_log_at && last_error_log_at > (last_event_at - 2.minutes)
88 92
   end
89 93
 
90 94
   def create_event(attrs)
@@ -114,6 +118,14 @@ class Agent < ActiveRecord::Base
114 118
     @memoized_last_event_at ||= most_recent_event.try(:created_at)
115 119
   end
116 120
 
121
+  def set_default_schedule
122
+    self.schedule = default_schedule unless schedule.present? || cannot_be_scheduled?
123
+  end
124
+
125
+  def unschedule_if_cannot_schedule
126
+    self.schedule = nil if cannot_be_scheduled?
127
+  end
128
+
117 129
   def default_schedule
118 130
     self.class.default_schedule
119 131
   end
@@ -181,6 +193,16 @@ class Agent < ActiveRecord::Base
181 193
     update_event_expirations! if keep_events_for_changed?
182 194
   end
183 195
 
196
+  def delete_logs!
197
+    logs.delete_all
198
+    update_column :last_error_log_at, nil
199
+  end
200
+
201
+  def log(message, options = {})
202
+    puts "Agent##{id}: #{message}" unless Rails.env.test?
203
+    AgentLog.log_for_agent(self, message, options)
204
+  end
205
+
184 206
   def update_event_expirations!
185 207
     if keep_events_for == 0
186 208
       events.update_all :expires_at => nil

+ 3 - 0
app/models/agent_log.rb

@@ -14,6 +14,9 @@ class AgentLog < ActiveRecord::Base
14 14
       oldest_id_to_keep = agent.logs.limit(1).offset(log_length - 1).pluck("agent_logs.id")
15 15
       agent.logs.where("agent_logs.id < ?", oldest_id_to_keep).delete_all
16 16
     end
17
+
18
+    agent.update_column :last_error_log_at, Time.now if log.level >= 4
19
+
17 20
     log
18 21
   end
19 22
 

+ 13 - 13
app/models/agents/adioso_agent.rb

@@ -29,22 +29,22 @@ module Agents
29 29
 
30 30
     def default_options
31 31
       {
32
-        :start_date => Date.today.httpdate[0..15],
33
-        :end_date   => Date.today.plus_with_duration(100).httpdate[0..15],
34
-        :from       => "New York",
35
-        :to         => "Chicago",
36
-        :username   => "xx",
37
-        :password   => "xx",
38
-				:expected_update_period_in_days => "1"
32
+        'start_date' => Date.today.httpdate[0..15],
33
+        'end_date'   => Date.today.plus_with_duration(100).httpdate[0..15],
34
+        'from'       => "New York",
35
+        'to'         => "Chicago",
36
+        'username'   => "xx",
37
+        'password'   => "xx",
38
+				'expected_update_period_in_days' => "1"
39 39
       }
40 40
     end
41 41
 
42 42
     def working?
43
-      event_created_within(options[:expected_update_period_in_days]) && !recent_error_logs?
43
+      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
44 44
     end
45 45
 
46 46
     def validate_options
47
-			unless %w[start_date end_date from to username password expected_update_period_in_days].all? { |field| options[field.to_sym].present? }
47
+			unless %w[start_date end_date from to username password expected_update_period_in_days].all? { |field| options[field].present? }
48 48
 				errors.add(:base, "All fields are required")
49 49
 			end
50 50
 		end
@@ -54,9 +54,9 @@ module Agents
54 54
     end
55 55
 
56 56
     def check
57
-      auth_options = {:basic_auth => {:username =>options[:username], :password=>options[:password]}}
58
-      parse_response = HTTParty.get "http://api.adioso.com/v2/search/parse?q=#{URI.encode(options[:from])}+to+#{URI.encode(options[:to])}", auth_options
59
-      fare_request = parse_response["search_url"].gsub /(end=)(\d*)([^\d]*)(\d*)/, "\\1#{date_to_unix_epoch(options[:end_date])}\\3#{date_to_unix_epoch(options[:start_date])}"
57
+      auth_options = {:basic_auth => {:username =>options[:username], :password=>options['password']}}
58
+      parse_response = HTTParty.get "http://api.adioso.com/v2/search/parse?q=#{URI.encode(options['from'])}+to+#{URI.encode(options['to'])}", auth_options
59
+      fare_request = parse_response["search_url"].gsub /(end=)(\d*)([^\d]*)(\d*)/, "\\1#{date_to_unix_epoch(options['end_date'])}\\3#{date_to_unix_epoch(options['start_date'])}"
60 60
       fare = HTTParty.get fare_request, auth_options
61 61
 
62 62
 			if fare["warnings"]
@@ -64,7 +64,7 @@ module Agents
64 64
 			else
65 65
 				event = fare["results"].min {|a,b| a["cost"] <=> b["cost"]}
66 66
 				event["date"]  = Time.at(event["date"]).to_date.httpdate[0..15]
67
-				event["route"] = "#{options[:from]} to #{options[:to]}" 
67
+				event["route"] = "#{options['from']} to #{options['to']}"
68 68
 				create_event :payload => event
69 69
 			end
70 70
     end

+ 14 - 14
app/models/agents/digest_email_agent.rb

@@ -9,7 +9,7 @@ module Agents
9 9
     description <<-MD
10 10
       The DigestEmailAgent collects any Events sent to it and sends them all via email when run.
11 11
       The email will be sent to your account's address and will have a `subject` and an optional `headline` before
12
-      listing the Events.  If the Events' payloads contain a `:message`, that will be highlighted, otherwise everything in
12
+      listing the Events.  If the Events' payloads contain a `message`, that will be highlighted, otherwise everything in
13 13
       their payloads will be shown.
14 14
 
15 15
       Set `expected_receive_period_in_days` to the maximum amount of time that you'd expect to pass between Events being received by this Agent.
@@ -17,29 +17,29 @@ module Agents
17 17
 
18 18
     def default_options
19 19
       {
20
-          :subject => "You have some notifications!",
21
-          :headline => "Your notifications:",
22
-          :expected_receive_period_in_days => "2"
20
+          'subject' => "You have some notifications!",
21
+          'headline' => "Your notifications:",
22
+          'expected_receive_period_in_days' => "2"
23 23
       }
24 24
     end
25 25
 
26 26
     def receive(incoming_events)
27 27
       incoming_events.each do |event|
28
-        self.memory[:queue] ||= []
29
-        self.memory[:queue] << event.payload
30
-        self.memory[:events] ||= []
31
-        self.memory[:events] << event.id
28
+        self.memory['queue'] ||= []
29
+        self.memory['queue'] << event.payload
30
+        self.memory['events'] ||= []
31
+        self.memory['events'] << event.id
32 32
       end
33 33
     end
34 34
 
35 35
     def check
36
-      if self.memory[:queue] && self.memory[:queue].length > 0
37
-        ids = self.memory[:events].join(",")
38
-        groups = self.memory[:queue].map { |payload| present(payload) }
36
+      if self.memory['queue'] && self.memory['queue'].length > 0
37
+        ids = self.memory['events'].join(",")
38
+        groups = self.memory['queue'].map { |payload| present(payload) }
39 39
         log "Sending digest mail to #{user.email} with events [#{ids}]"
40
-        SystemMailer.delay.send_message(:to => user.email, :subject => options[:subject], :headline => options[:headline], :groups => groups)
41
-        self.memory[:queue] = []
42
-        self.memory[:events] = []
40
+        SystemMailer.delay.send_message(:to => user.email, :subject => options['subject'], :headline => options['headline'], :groups => groups)
41
+        self.memory['queue'] = []
42
+        self.memory['events'] = []
43 43
       end
44 44
     end
45 45
   end

+ 4 - 4
app/models/agents/email_agent.rb

@@ -16,16 +16,16 @@ module Agents
16 16
 
17 17
     def default_options
18 18
       {
19
-          :subject => "You have a notification!",
20
-          :headline => "Your notification:",
21
-          :expected_receive_period_in_days => "2"
19
+          'subject' => "You have a notification!",
20
+          'headline' => "Your notification:",
21
+          'expected_receive_period_in_days' => "2"
22 22
       }
23 23
     end
24 24
 
25 25
     def receive(incoming_events)
26 26
       incoming_events.each do |event|
27 27
         log "Sending digest mail to #{user.email} with event #{event.id}"
28
-        SystemMailer.delay.send_message(:to => user.email, :subject => options[:subject], :headline => options[:headline], :groups => [present(event.payload)])
28
+        SystemMailer.delay.send_message(:to => user.email, :subject => options['subject'], :headline => options['headline'], :groups => [present(event.payload)])
29 29
       end
30 30
     end
31 31
   end

+ 22 - 22
app/models/agents/event_formatting_agent.rb

@@ -8,20 +8,20 @@ module Agents
8 8
       For example, here is a possible Event:
9 9
 
10 10
           {
11
-            :high => {
12
-              :celsius => "18",
13
-              :fahreinheit => "64"
11
+            "high": {
12
+              "celsius": "18",
13
+              "fahreinheit": "64"
14 14
             },
15
-            :conditions => "Rain showers",
16
-            :data   => "This is some data"
15
+            "conditions": "Rain showers",
16
+            "data": "This is some data"
17 17
           }
18 18
 
19 19
       You may want to send this event to another Agent, for example a Twilio Agent, which expects a `message` key.
20 20
       You can use an Event Formatting Agent's `instructions` setting to do this in the following way:
21 21
 
22
-          instructions: {
23
-            message: "Today's conditions look like <$.conditions> with a high temperature of <$.high.celsius> degrees Celsius.",
24
-            subject: "$.data"
22
+          "instructions": {
23
+            "message": "Today's conditions look like <$.conditions> with a high temperature of <$.high.celsius> degrees Celsius.",
24
+            "subject": "$.data"
25 25
           }
26 26
 
27 27
       JSONPaths must be between < and > . Make sure that you don't use these symbols anywhere else.
@@ -29,8 +29,8 @@ module Agents
29 29
       Events generated by this possible Event Formatting Agent will look like:
30 30
 
31 31
           {
32
-            :message => "Today's conditions look like Rain showers with a high temperature of 18 degrees Celsius.",
33
-            :subject => "This is some data"
32
+            "message": "Today's conditions look like Rain showers with a high temperature of 18 degrees Celsius.",
33
+            "subject": "This is some data"
34 34
           }
35 35
 
36 36
       If you want to retain original contents of events and only add new keys, then set `mode` to `merge`, otherwise set it to `clean`.
@@ -40,25 +40,25 @@ module Agents
40 40
       To CGI escape output (for example when creating a link), prefix with `escape`, like so:
41 41
 
42 42
           {
43
-            :message => "A peak was on Twitter in <$.group_by>.  Search: https://twitter.com/search?q=<escape $.group_by>"
43
+            "message": "A peak was on Twitter in <$.group_by>.  Search: https://twitter.com/search?q=<escape $.group_by>"
44 44
           }
45 45
     MD
46 46
 
47 47
     event_description "User defined"
48 48
 
49 49
     def validate_options
50
-      errors.add(:base, "instructions, mode, skip_agent, and skip_created_at all need to be present.") unless options[:instructions].present? and options[:mode].present? and options[:skip_agent].present? and options[:skip_created_at].present?
50
+      errors.add(:base, "instructions, mode, skip_agent, and skip_created_at all need to be present.") unless options['instructions'].present? and options['mode'].present? and options['skip_agent'].present? and options['skip_created_at'].present?
51 51
     end
52 52
 
53 53
     def default_options
54 54
       {
55
-        :instructions => {
56
-          :message =>  "You received a text <$.text> from <$.fields.from>",
57
-          :some_other_field => "Looks like the weather is going to be <$.fields.weather>"
55
+        'instructions' => {
56
+          'message' =>  "You received a text <$.text> from <$.fields.from>",
57
+          'some_other_field' => "Looks like the weather is going to be <$.fields.weather>"
58 58
         },
59
-        :mode => "clean",
60
-        :skip_agent => "false",
61
-        :skip_created_at => "false"
59
+        'mode' => "clean",
60
+        'skip_agent' => "false",
61
+        'skip_created_at' => "false"
62 62
       }
63 63
     end
64 64
 
@@ -68,10 +68,10 @@ module Agents
68 68
 
69 69
     def receive(incoming_events)
70 70
       incoming_events.each do |event|
71
-        formatted_event = options[:mode].to_s == "merge" ? event.payload : {}
72
-        options[:instructions].each_pair {|key, value| formatted_event[key] = Utils.interpolate_jsonpaths(value, event.payload) }
73
-        formatted_event[:agent] = Agent.find(event.agent_id).type.slice!(8..-1) unless options[:skip_agent].to_s == "true"
74
-        formatted_event[:created_at] = event.created_at unless options[:skip_created_at].to_s == "true"
71
+        formatted_event = options['mode'].to_s == "merge" ? event.payload : {}
72
+        options['instructions'].each_pair {|key, value| formatted_event[key] = Utils.interpolate_jsonpaths(value, event.payload) }
73
+        formatted_event['agent'] = Agent.find(event.agent_id).type.slice!(8..-1) unless options['skip_agent'].to_s == "true"
74
+        formatted_event['created_at'] = event.created_at unless options['skip_created_at'].to_s == "true"
75 75
         create_event :payload => formatted_event
76 76
       end
77 77
     end

+ 262 - 109
app/models/agents/human_task_agent.rb

@@ -9,9 +9,13 @@ module Agents
9 9
 
10 10
       HITs can be created in response to events, or on a schedule.  Set `trigger_on` to either `schedule` or `event`.
11 11
 
12
+      # Schedule
13
+
12 14
       The schedule of this Agent is how often it should check for completed HITs, __NOT__ how often to submit one.  To configure how often a new HIT
13 15
       should be submitted when in `schedule` mode, set `submission_period` to a number of hours.
14 16
 
17
+      # Example
18
+
15 19
       If created with an event, all HIT fields can contain interpolated values via [JSONPaths](http://goessner.net/articles/JsonPath/) placed between < and > characters.
16 20
       For example, if the incoming event was a Twitter event, you could make a HITT to rate its sentiment like this:
17 21
 
@@ -58,8 +62,52 @@ module Agents
58 62
       which contain `key` and `text`.  For _free\\_text_, the special configuration options are all optional, and are
59 63
       `default`, `min_length`, and `max_length`.
60 64
 
61
-      If all of the `questions` are of `type` _selection_, you can set `take_majority` to _true_ at the top level to
62
-      automatically select the majority vote for each question across all `assignments`.  If all selections are numeric, an `average_answer` will also be generated.
65
+      # Combining answers
66
+
67
+      There are a couple of ways to combine HITs that have multiple `assignments`, all of which involve setting `combination_mode` at the top level.
68
+
69
+      ## Taking the majority
70
+
71
+      Option 1: if all of your `questions` are of `type` _selection_, you can set `combination_mode` to `take_majority`.
72
+      This will cause the Agent to automatically select the majority vote for each question across all `assignments` and return it as `majority_answer`.
73
+      If all selections are numeric, an `average_answer` will also be generated.
74
+
75
+      Option 2: you can have the Agent ask additional human workers to rank the `assignments` and return the most highly ranked answer.
76
+      To do this, set `combination_mode` to `poll` and provide a `poll_options` object.  Here is an example:
77
+
78
+          {
79
+            "trigger_on": "schedule",
80
+            "submission_period": 12,
81
+            "combination_mode": "poll",
82
+            "poll_options": {
83
+              "title": "Take a poll about some jokes",
84
+              "instructions": "Please rank these jokes from most funny (5) to least funny (1)",
85
+              "assignments": 3,
86
+              "row_template": "<$.joke>"
87
+            },
88
+            "hit": {
89
+              "assignments": 5,
90
+              "title": "Tell a joke",
91
+              "description": "Please tell me a joke",
92
+              "reward": 0.05,
93
+              "lifetime_in_seconds": "3600",
94
+              "questions": [
95
+                {
96
+                  "type": "free_text",
97
+                  "key": "joke",
98
+                  "name": "Your joke",
99
+                  "required": "true",
100
+                  "question": "Joke",
101
+                  "min_length": "2",
102
+                  "max_length": "2000"
103
+                }
104
+              ]
105
+            }
106
+          }
107
+
108
+      Resulting events will have the original `answers`, as well as the `poll` results, and a field called `best_answer` that contains the best answer as determined by the poll.
109
+
110
+      # Other settings
63 111
 
64 112
       `lifetime_in_seconds` is the number of seconds a HIT is left on Amazon before it's automatically closed.  The default is 1 day.
65 113
 
@@ -70,73 +118,83 @@ module Agents
70 118
       Events look like:
71 119
 
72 120
           {
121
+            "answers": [
122
+              {
123
+                "feedback": "Hello!",
124
+                "sentiment": "happy"
125
+              }
126
+            ]
73 127
           }
74 128
     MD
75 129
 
76 130
     def validate_options
77
-      options[:hit] ||= {}
78
-      options[:hit][:questions] ||= []
79
-
80
-      errors.add(:base, "'trigger_on' must be one of 'schedule' or 'event'") unless %w[schedule event].include?(options[:trigger_on])
81
-      errors.add(:base, "'hit.assignments' should specify the number of HIT assignments to create") unless options[:hit][:assignments].present? && options[:hit][:assignments].to_i > 0
82
-      errors.add(:base, "'hit.title' must be provided") unless options[:hit][:title].present?
83
-      errors.add(:base, "'hit.description' must be provided") unless options[:hit][:description].present?
84
-      errors.add(:base, "'hit.questions' must be provided") unless options[:hit][:questions].present? && options[:hit][:questions].length > 0
85
-
86
-      if options[:trigger_on] == "event"
87
-        errors.add(:base, "'expected_receive_period_in_days' is required when 'trigger_on' is set to 'event'") unless options[:expected_receive_period_in_days].present?
88
-      elsif options[:trigger_on] == "schedule"
89
-        errors.add(:base, "'submission_period' must be set to a positive number of hours when 'trigger_on' is set to 'schedule'") unless options[:submission_period].present? && options[:submission_period].to_i > 0
131
+      options['hit'] ||= {}
132
+      options['hit']['questions'] ||= []
133
+
134
+      errors.add(:base, "'trigger_on' must be one of 'schedule' or 'event'") unless %w[schedule event].include?(options['trigger_on'])
135
+      errors.add(:base, "'hit.assignments' should specify the number of HIT assignments to create") unless options['hit']['assignments'].present? && options['hit']['assignments'].to_i > 0
136
+      errors.add(:base, "'hit.title' must be provided") unless options['hit']['title'].present?
137
+      errors.add(:base, "'hit.description' must be provided") unless options['hit']['description'].present?
138
+      errors.add(:base, "'hit.questions' must be provided") unless options['hit']['questions'].present? && options['hit']['questions'].length > 0
139
+
140
+      if options['trigger_on'] == "event"
141
+        errors.add(:base, "'expected_receive_period_in_days' is required when 'trigger_on' is set to 'event'") unless options['expected_receive_period_in_days'].present?
142
+      elsif options['trigger_on'] == "schedule"
143
+        errors.add(:base, "'submission_period' must be set to a positive number of hours when 'trigger_on' is set to 'schedule'") unless options['submission_period'].present? && options['submission_period'].to_i > 0
90 144
       end
91 145
 
92
-      if options[:hit][:questions].any? { |question| [:key, :name, :required, :type, :question].any? {|k| !question[k].present? } }
146
+      if options['hit']['questions'].any? { |question| %w[key name required type question].any? {|k| !question[k].present? } }
93 147
         errors.add(:base, "all questions must set 'key', 'name', 'required', 'type', and 'question'")
94 148
       end
95 149
 
96
-      if options[:hit][:questions].any? { |question| question[:type] == "selection" && (!question[:selections].present? || question[:selections].length == 0 || !question[:selections].all? {|s| s[:key].present? } || !question[:selections].all? { |s| s[:text].present? })}
150
+      if options['hit']['questions'].any? { |question| question['type'] == "selection" && (!question['selections'].present? || question['selections'].length == 0 || !question['selections'].all? {|s| s['key'].present? } || !question['selections'].all? { |s| s['text'].present? })}
97 151
         errors.add(:base, "all questions of type 'selection' must have a selections array with selections that set 'key' and 'name'")
98 152
       end
99 153
 
100
-      if options[:take_majority] == "true" && options[:hit][:questions].any? { |question| question[:type] != "selection" }
154
+      if take_majority? && options['hit']['questions'].any? { |question| question['type'] != "selection" }
101 155
         errors.add(:base, "all questions must be of type 'selection' to use the 'take_majority' option")
102 156
       end
157
+
158
+      if create_poll?
159
+        errors.add(:base, "poll_options is required when combination_mode is set to 'poll' and must have the keys 'title', 'instructions', 'row_template', and 'assignments'") unless options['poll_options'].is_a?(Hash) && options['poll_options']['title'].present? && options['poll_options']['instructions'].present? && options['poll_options']['row_template'].present? && options['poll_options']['assignments'].to_i > 0
160
+      end
103 161
     end
104 162
 
105 163
     def default_options
106 164
       {
107
-        :expected_receive_period_in_days => 2,
108
-        :trigger_on => "event",
109
-        :hit =>
165
+        'expected_receive_period_in_days' => 2,
166
+        'trigger_on' => "event",
167
+        'hit' =>
110 168
           {
111
-            :assignments => 1,
112
-            :title => "Sentiment evaluation",
113
-            :description => "Please rate the sentiment of this message: '<$.message>'",
114
-            :reward => 0.05,
115
-            :lifetime_in_seconds => 24 * 60 * 60,
116
-            :questions =>
169
+            'assignments' => 1,
170
+            'title' => "Sentiment evaluation",
171
+            'description' => "Please rate the sentiment of this message: '<$.message>'",
172
+            'reward' => 0.05,
173
+            'lifetime_in_seconds' => 24 * 60 * 60,
174
+            'questions' =>
117 175
               [
118 176
                 {
119
-                  :type => "selection",
120
-                  :key => "sentiment",
121
-                  :name => "Sentiment",
122
-                  :required => "true",
123
-                  :question => "Please select the best sentiment value:",
124
-                  :selections =>
177
+                  'type' => "selection",
178
+                  'key' => "sentiment",
179
+                  'name' => "Sentiment",
180
+                  'required' => "true",
181
+                  'question' => "Please select the best sentiment value:",
182
+                  'selections' =>
125 183
                     [
126
-                      { :key => "happy", :text => "Happy" },
127
-                      { :key => "sad", :text => "Sad" },
128
-                      { :key => "neutral", :text => "Neutral" }
184
+                      { 'key' => "happy", 'text' => "Happy" },
185
+                      { 'key' => "sad", 'text' => "Sad" },
186
+                      { 'key' => "neutral", 'text' => "Neutral" }
129 187
                     ]
130 188
                 },
131 189
                 {
132
-                  :type => "free_text",
133
-                  :key => "feedback",
134
-                  :name => "Have any feedback for us?",
135
-                  :required => "false",
136
-                  :question => "Feedback",
137
-                  :default => "Type here...",
138
-                  :min_length => "2",
139
-                  :max_length => "2000"
190
+                  'type' => "free_text",
191
+                  'key' => "feedback",
192
+                  'name' => "Have any feedback for us?",
193
+                  'required' => "false",
194
+                  'question' => "Feedback",
195
+                  'default' => "Type here...",
196
+                  'min_length' => "2",
197
+                  'max_length' => "2000"
140 198
                 }
141 199
               ]
142 200
           }
@@ -144,110 +202,205 @@ module Agents
144 202
     end
145 203
 
146 204
     def working?
147
-      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
205
+      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
148 206
     end
149 207
 
150 208
     def check
151 209
       review_hits
152 210
 
153
-      if options[:trigger_on] == "schedule" && (memory[:last_schedule] || 0) <= Time.now.to_i - options[:submission_period].to_i * 60 * 60
154
-        memory[:last_schedule] = Time.now.to_i
155
-        create_hit
211
+      if options['trigger_on'] == "schedule" && (memory['last_schedule'] || 0) <= Time.now.to_i - options['submission_period'].to_i * 60 * 60
212
+        memory['last_schedule'] = Time.now.to_i
213
+        create_basic_hit
156 214
       end
157 215
     end
158 216
 
159 217
     def receive(incoming_events)
160
-      if options[:trigger_on] == "event"
218
+      if options['trigger_on'] == "event"
161 219
         incoming_events.each do |event|
162
-          create_hit event
220
+          create_basic_hit event
163 221
         end
164 222
       end
165 223
     end
166 224
 
167 225
     protected
168 226
 
227
+    def take_majority?
228
+      options['combination_mode'] == "take_majority" || options['take_majority'] == "true"
229
+    end
230
+
231
+    def create_poll?
232
+      options['combination_mode'] == "poll"
233
+    end
234
+
235
+    def event_for_hit(hit_id)
236
+      if memory['hits'][hit_id].is_a?(Hash)
237
+        Event.find_by_id(memory['hits'][hit_id]['event_id'])
238
+      else
239
+        nil
240
+      end
241
+    end
242
+
243
+    def hit_type(hit_id)
244
+      if memory['hits'][hit_id].is_a?(Hash) && memory['hits'][hit_id]['type']
245
+        memory['hits'][hit_id]['type']
246
+      else
247
+        'user'
248
+      end
249
+    end
250
+
169 251
     def review_hits
170 252
       reviewable_hit_ids = RTurk::GetReviewableHITs.create.hit_ids
171
-      my_reviewed_hit_ids = reviewable_hit_ids & (memory[:hits] || {}).keys.map(&:to_s)
253
+      my_reviewed_hit_ids = reviewable_hit_ids & (memory['hits'] || {}).keys
172 254
       if reviewable_hit_ids.length > 0
173 255
         log "MTurk reports #{reviewable_hit_ids.length} HITs, of which I own [#{my_reviewed_hit_ids.to_sentence}]"
174 256
       end
257
+
175 258
       my_reviewed_hit_ids.each do |hit_id|
176 259
         hit = RTurk::Hit.new(hit_id)
177 260
         assignments = hit.assignments
178 261
 
179 262
         log "Looking at HIT #{hit_id}.  I found #{assignments.length} assignments#{" with the statuses: #{assignments.map(&:status).to_sentence}" if assignments.length > 0}"
180 263
         if assignments.length == hit.max_assignments && assignments.all? { |assignment| assignment.status == "Submitted" }
181
-          payload = { :answers => assignments.map(&:answers) }
182
-
183
-          if options[:take_majority] == "true"
184
-            counts = {}
185
-            options[:hit][:questions].each do |question|
186
-              question_counts = question[:selections].inject({}) { |memo, selection| memo[selection[:key]] = 0; memo }
187
-              assignments.each do |assignment|
188
-                answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
189
-                answer = answers[question[:key]]
190
-                question_counts[answer] += 1
264
+          inbound_event = event_for_hit(hit_id)
265
+
266
+          if hit_type(hit_id) == 'poll'
267
+            # handle completed polls
268
+
269
+            log "Handling a poll: #{hit_id}"
270
+
271
+            scores = {}
272
+            assignments.each do |assignment|
273
+              assignment.answers.each do |index, rating|
274
+                scores[index] ||= 0
275
+                scores[index] += rating.to_i
191 276
               end
192
-              counts[question[:key]] = question_counts
193 277
             end
194
-            payload[:counts] = counts
195 278
 
196
-            majority_answer = counts.inject({}) do |memo, (key, question_counts)|
197
-              memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
198
-              memo
199
-            end
200
-            payload[:majority_answer] = majority_answer
201
-
202
-            if all_questions_are_numeric?
203
-              average_answer = counts.inject({}) do |memo, (key, question_counts)|
204
-                sum = divisor = 0
205
-                question_counts.to_a.each do |num, count|
206
-                  sum += num.to_s.to_f * count
207
-                  divisor += count
279
+            top_answer = scores.to_a.sort {|b, a| a.last <=> b.last }.first.first
280
+
281
+            payload = {
282
+              'answers' => memory['hits'][hit_id]['answers'],
283
+              'poll' => assignments.map(&:answers),
284
+              'best_answer' => memory['hits'][hit_id]['answers'][top_answer.to_i - 1]
285
+            }
286
+
287
+            event = create_event :payload => payload
288
+            log "Event emitted with answer(s) for poll", :outbound_event => event, :inbound_event => inbound_event
289
+          else
290
+            # handle normal completed HITs
291
+            payload = { 'answers' => assignments.map(&:answers) }
292
+
293
+            if take_majority?
294
+              counts = {}
295
+              options['hit']['questions'].each do |question|
296
+                question_counts = question['selections'].inject({}) { |memo, selection| memo[selection['key']] = 0; memo }
297
+                assignments.each do |assignment|
298
+                  answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
299
+                  answer = answers[question['key']]
300
+                  question_counts[answer] += 1
208 301
                 end
209
-                memo[key] = sum / divisor.to_f
302
+                counts[question['key']] = question_counts
303
+              end
304
+              payload['counts'] = counts
305
+
306
+              majority_answer = counts.inject({}) do |memo, (key, question_counts)|
307
+                memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
210 308
                 memo
211 309
               end
212
-              payload[:average_answer] = average_answer
310
+              payload['majority_answer'] = majority_answer
311
+
312
+              if all_questions_are_numeric?
313
+                average_answer = counts.inject({}) do |memo, (key, question_counts)|
314
+                  sum = divisor = 0
315
+                  question_counts.to_a.each do |num, count|
316
+                    sum += num.to_s.to_f * count
317
+                    divisor += count
318
+                  end
319
+                  memo[key] = sum / divisor.to_f
320
+                  memo
321
+                end
322
+                payload['average_answer'] = average_answer
323
+              end
213 324
             end
214
-          end
215 325
 
216
-          event = create_event :payload => payload
217
-          log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => Event.find_by_id(memory[:hits][hit_id.to_sym])
326
+            if create_poll?
327
+              questions = []
328
+              selections = 5.times.map { |i| { 'key' => i+1, 'text' => i+1 } }.reverse
329
+              assignments.length.times do |index|
330
+                questions << {
331
+                  'type' => "selection",
332
+                  'name' => "Item #{index + 1}",
333
+                  'key' => index,
334
+                  'required' => "true",
335
+                  'question' => Utils.interpolate_jsonpaths(options['poll_options']['row_template'], assignments[index].answers),
336
+                  'selections' => selections
337
+                }
338
+              end
339
+
340
+              poll_hit = create_hit 'title' => options['poll_options']['title'],
341
+                                    'description' => options['poll_options']['instructions'],
342
+                                    'questions' => questions,
343
+                                    'assignments' => options['poll_options']['assignments'],
344
+                                    'lifetime_in_seconds' => options['poll_options']['lifetime_in_seconds'],
345
+                                    'reward' => options['poll_options']['reward'],
346
+                                    'payload' => inbound_event && inbound_event.payload,
347
+                                    'metadata' => { 'type' => 'poll',
348
+                                                    'original_hit' => hit_id,
349
+                                                    'answers' => assignments.map(&:answers),
350
+                                                    'event_id' => inbound_event && inbound_event.id }
351
+
352
+              log "Poll HIT created with ID #{poll_hit.id} and URL #{poll_hit.url}.  Original HIT: #{hit_id}", :inbound_event => inbound_event
353
+            else
354
+              event = create_event :payload => payload
355
+              log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => inbound_event
356
+            end
357
+          end
218 358
 
219 359
           assignments.each(&:approve!)
220 360
           hit.dispose!
221 361
 
222
-          memory[:hits].delete(hit_id.to_sym)
362
+          memory['hits'].delete(hit_id)
223 363
         end
224 364
       end
225 365
     end
226 366
 
227 367
     def all_questions_are_numeric?
228
-      options[:hit][:questions].all? do |question|
229
-        question[:selections].all? do |selection|
230
-          selection[:key] == selection[:key].to_f.to_s || selection[:key] == selection[:key].to_i.to_s
368
+      options['hit']['questions'].all? do |question|
369
+        question['selections'].all? do |selection|
370
+          selection['key'] == selection['key'].to_f.to_s || selection['key'] == selection['key'].to_i.to_s
231 371
         end
232 372
       end
233 373
     end
234 374
 
235
-    def create_hit(event = nil)
236
-      payload = event ? event.payload : {}
237
-      title = Utils.interpolate_jsonpaths(options[:hit][:title], payload).strip
238
-      description = Utils.interpolate_jsonpaths(options[:hit][:description], payload).strip
239
-      questions = Utils.recursively_interpolate_jsonpaths(options[:hit][:questions], payload)
375
+    def create_basic_hit(event = nil)
376
+      hit = create_hit 'title' => options['hit']['title'],
377
+                       'description' => options['hit']['description'],
378
+                       'questions' => options['hit']['questions'],
379
+                       'assignments' => options['hit']['assignments'],
380
+                       'lifetime_in_seconds' => options['hit']['lifetime_in_seconds'],
381
+                       'reward' => options['hit']['reward'],
382
+                       'payload' => event && event.payload,
383
+                       'metadata' => { 'event_id' => event && event.id }
384
+
385
+      log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
386
+    end
387
+
388
+    def create_hit(opts = {})
389
+      payload = opts['payload'] || {}
390
+      title = Utils.interpolate_jsonpaths(opts['title'], payload).strip
391
+      description = Utils.interpolate_jsonpaths(opts['description'], payload).strip
392
+      questions = Utils.recursively_interpolate_jsonpaths(opts['questions'], payload)
240 393
       hit = RTurk::Hit.create(:title => title) do |hit|
241
-        hit.max_assignments = (options[:hit][:assignments] || 1).to_i
394
+        hit.max_assignments = (opts['assignments'] || 1).to_i
242 395
         hit.description = description
243
-        hit.lifetime = (options[:hit][:lifetime_in_seconds] || 24 * 60 * 60).to_i
396
+        hit.lifetime = (opts['lifetime_in_seconds'] || 24 * 60 * 60).to_i
244 397
         hit.question_form AgentQuestionForm.new(:title => title, :description => description, :questions => questions)
245
-        hit.reward = (options[:hit][:reward] || 0.05).to_f
398
+        hit.reward = (opts['reward'] || 0.05).to_f
246 399
         #hit.qualifications.add :approval_rate, { :gt => 80 }
247 400
       end
248
-      memory[:hits] ||= {}
249
-      memory[:hits][hit.id] = event && event.id
250
-      log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
401
+      memory['hits'] ||= {}
402
+      memory['hits'][hit.id] = opts['metadata'] || {}
403
+      hit
251 404
     end
252 405
 
253 406
     # RTurk Question Form
@@ -268,34 +421,34 @@ module Agents
268 421
         @questions.each.with_index do |question, index|
269 422
           Question do
270 423
             QuestionIdentifier do
271
-              text question[:key] || "question_#{index}"
424
+              text question['key'] || "question_#{index}"
272 425
             end
273 426
             DisplayName do
274
-              text question[:name] || "Question ##{index}"
427
+              text question['name'] || "Question ##{index}"
275 428
             end
276 429
             IsRequired do
277
-              text question[:required] || 'true'
430
+              text question['required'] || 'true'
278 431
             end
279 432
             QuestionContent do
280 433
               Text do
281
-                text question[:question]
434
+                text question['question']
282 435
               end
283 436
             end
284 437
             AnswerSpecification do
285
-              if question[:type] == "selection"
438
+              if question['type'] == "selection"
286 439
 
287 440
                 SelectionAnswer do
288 441
                   StyleSuggestion do
289 442
                     text 'radiobutton'
290 443
                   end
291 444
                   Selections do
292
-                    question[:selections].each do |selection|
445
+                    question['selections'].each do |selection|
293 446
                       Selection do
294 447
                         SelectionIdentifier do
295
-                          text selection[:key]
448
+                          text selection['key']
296 449
                         end
297 450
                         Text do
298
-                          text selection[:text]
451
+                          text selection['text']
299 452
                         end
300 453
                       end
301 454
                     end
@@ -305,18 +458,18 @@ module Agents
305 458
               else
306 459
 
307 460
                 FreeTextAnswer do
308
-                  if question[:min_length].present? || question[:max_length].present?
461
+                  if question['min_length'].present? || question['max_length'].present?
309 462
                     Constraints do
310 463
                       lengths = {}
311
-                      lengths[:minLength] = question[:min_length].to_s if question[:min_length].present?
312
-                      lengths[:maxLength] = question[:max_length].to_s if question[:max_length].present?
464
+                      lengths['minLength'] = question['min_length'].to_s if question['min_length'].present?
465
+                      lengths['maxLength'] = question['max_length'].to_s if question['max_length'].present?
313 466
                       Length lengths
314 467
                     end
315 468
                   end
316 469
 
317
-                  if question[:default].present?
470
+                  if question['default'].present?
318 471
                     DefaultText do
319
-                      text question[:default]
472
+                      text question['default']
320 473
                     end
321 474
                   end
322 475
                 end
@@ -328,4 +481,4 @@ module Agents
328 481
       end
329 482
     end
330 483
   end
331
-end
484
+end

+ 2 - 2
app/models/agents/manual_event_agent.rb

@@ -14,8 +14,8 @@ module Agents
14 14
     end
15 15
 
16 16
     def handle_details_post(params)
17
-      if params[:payload]
18
-        create_event(:payload => params[:payload])
17
+      if params['payload']
18
+        create_event(:payload => params['payload'])
19 19
         { :success => true }
20 20
       else
21 21
         { :success => false, :error => "You must provide a JSON payload" }

+ 27 - 29
app/models/agents/peak_detector_agent.rb

@@ -28,22 +28,22 @@ module Agents
28 28
     MD
29 29
 
30 30
     def validate_options
31
-      unless options[:expected_receive_period_in_days].present? && options[:message].present? && options[:value_path].present?
31
+      unless options['expected_receive_period_in_days'].present? && options['message'].present? && options['value_path'].present?
32 32
         errors.add(:base, "expected_receive_period_in_days, value_path, and message are required")
33 33
       end
34 34
     end
35 35
 
36 36
     def default_options
37 37
       {
38
-          :expected_receive_period_in_days => "2",
39
-          :group_by_path => "filter",
40
-          :value_path => "count",
41
-          :message => "A peak was found"
38
+        'expected_receive_period_in_days' => "2",
39
+        'group_by_path' => "filter",
40
+        'value_path' => "count",
41
+        'message' => "A peak was found"
42 42
       }
43 43
     end
44 44
 
45 45
     def working?
46
-      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
46
+      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
47 47
     end
48 48
 
49 49
     def receive(incoming_events)
@@ -57,25 +57,23 @@ module Agents
57 57
     private
58 58
 
59 59
     def check_for_peak(group, event)
60
-      memory[:peaks] ||= {}
61
-      memory[:peaks][group] ||= []
60
+      memory['peaks'] ||= {}
61
+      memory['peaks'][group] ||= []
62 62
 
63
-      if memory[:data][group].length > 4 && (memory[:peaks][group].empty? || memory[:peaks][group].last < event.created_at.to_i - peak_spacing)
63
+      if memory['data'][group].length > 4 && (memory['peaks'][group].empty? || memory['peaks'][group].last < event.created_at.to_i - peak_spacing)
64 64
         average_value, standard_deviation = stats_for(group, :skip_last => 1)
65
-        newest_value, newest_time = memory[:data][group][-1].map(&:to_f)
66
-
67
-        #p [newest_value, average_value, average_value + std_multiple * standard_deviation, standard_deviation]
65
+        newest_value, newest_time = memory['data'][group][-1].map(&:to_f)
68 66
 
69 67
         if newest_value > average_value + std_multiple * standard_deviation
70
-          memory[:peaks][group] << newest_time
71
-          memory[:peaks][group].reject! { |p| p <= newest_time - window_duration }
72
-          create_event :payload => {:message => options[:message], :peak => newest_value, :peak_time => newest_time, :grouped_by => group.to_s}
68
+          memory['peaks'][group] << newest_time
69
+          memory['peaks'][group].reject! { |p| p <= newest_time - window_duration }
70
+          create_event :payload => { 'message' => options['message'], 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s }
73 71
         end
74 72
       end
75 73
     end
76 74
 
77 75
     def stats_for(group, options = {})
78
-      data = memory[:data][group].map { |d| d.first.to_f }
76
+      data = memory['data'][group].map { |d| d.first.to_f }
79 77
       data = data[0...(data.length - (options[:skip_last] || 0))]
80 78
       length = data.length.to_f
81 79
       mean = 0
@@ -94,39 +92,39 @@ module Agents
94 92
     end
95 93
 
96 94
     def window_duration
97
-      if options[:window_duration].present? # The older option
98
-        options[:window_duration].to_i
95
+      if options['window_duration'].present? # The older option
96
+        options['window_duration'].to_i
99 97
       else
100
-        (options[:window_duration_in_days] || 14).to_f.days
98
+        (options['window_duration_in_days'] || 14).to_f.days
101 99
       end
102 100
     end
103 101
 
104 102
     def std_multiple
105
-      (options[:std_multiple] || 3).to_f
103
+      (options['std_multiple'] || 3).to_f
106 104
     end
107 105
 
108 106
     def peak_spacing
109
-      if options[:peak_spacing].present? # The older option
110
-        options[:peak_spacing].to_i
107
+      if options['peak_spacing'].present? # The older option
108
+        options['peak_spacing'].to_i
111 109
       else
112
-        (options[:min_peak_spacing_in_days] || 2).to_f.days
110
+        (options['min_peak_spacing_in_days'] || 2).to_f.days
113 111
       end
114 112
     end
115 113
 
116 114
     def group_for(event)
117
-      ((options[:group_by_path].present? && Utils.value_at(event.payload, options[:group_by_path])) || 'no_group').to_sym
115
+      ((options['group_by_path'].present? && Utils.value_at(event.payload, options['group_by_path'])) || 'no_group')
118 116
     end
119 117
 
120 118
     def remember(group, event)
121
-      memory[:data] ||= {}
122
-      memory[:data][group] ||= []
123
-      memory[:data][group] << [Utils.value_at(event.payload, options[:value_path]), event.created_at.to_i]
119
+      memory['data'] ||= {}
120
+      memory['data'][group] ||= []
121
+      memory['data'][group] << [ Utils.value_at(event.payload, options['value_path']), event.created_at.to_i ]
124 122
       cleanup group
125 123
     end
126 124
 
127 125
     def cleanup(group)
128
-      newest_time = memory[:data][group].last.last
129
-      memory[:data][group].reject! { |value, time| time <= newest_time - window_duration }
126
+      newest_time = memory['data'][group].last.last
127
+      memory['data'][group].reject! { |value, time| time <= newest_time - window_duration }
130 128
     end
131 129
   end
132 130
 end

+ 4 - 4
app/models/agents/post_agent.rb

@@ -11,17 +11,17 @@ module Agents
11 11
 
12 12
     def default_options
13 13
       {
14
-        :post_url => "http://www.example.com",
15
-        :expected_receive_period_in_days => 1
14
+        'post_url' => "http://www.example.com",
15
+        'expected_receive_period_in_days' => 1
16 16
       }
17 17
     end
18 18
 
19 19
     def working?
20
-      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
20
+      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
21 21
     end
22 22
 
23 23
     def validate_options
24
-      unless options[:post_url].present? && options[:expected_receive_period_in_days].present?
24
+      unless options['post_url'].present? && options['expected_receive_period_in_days'].present?
25 25
         errors.add(:base, "post_url and expected_receive_period_in_days are required fields")
26 26
       end
27 27
     end

+ 10 - 10
app/models/agents/sentiment_agent.rb

@@ -28,31 +28,31 @@ module Agents
28 28
 
29 29
     def default_options
30 30
       {
31
-        :content => "$.message.text[*]",
32
-        :expected_receive_period_in_days => 1
31
+        'content' => "$.message.text[*]",
32
+        'expected_receive_period_in_days' => 1
33 33
       }
34 34
     end
35 35
 
36 36
     def working?
37
-      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
37
+      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
38 38
     end
39 39
 
40 40
     def receive(incoming_events)
41 41
       anew = self.class.sentiment_hash
42 42
       incoming_events.each do |event|
43
-        Utils.values_at(event.payload, options[:content]).each do |content|
43
+        Utils.values_at(event.payload, options['content']).each do |content|
44 44
           sent_values = sentiment_values anew, content
45
-          create_event :payload => { :content => content,
46
-                                     :valence => sent_values[0],
47
-                                     :arousal => sent_values[1],
48
-                                     :dominance => sent_values[2],
49
-                                     :original_event => event.payload }
45
+          create_event :payload => { 'content' => content,
46
+                                     'valence' => sent_values[0],
47
+                                     'arousal' => sent_values[1],
48
+                                     'dominance' => sent_values[2],
49
+                                     'original_event' => event.payload }
50 50
         end
51 51
       end
52 52
     end
53 53
 
54 54
     def validate_options
55
-      errors.add(:base, "content and expected_receive_period_in_days must be present") unless options[:content].present? && options[:expected_receive_period_in_days].present?
55
+      errors.add(:base, "content and expected_receive_period_in_days must be present") unless options['content'].present? && options['expected_receive_period_in_days'].present?
56 56
     end
57 57
 
58 58
     def self.sentiment_hash

+ 15 - 15
app/models/agents/translation_agent.rb

@@ -17,26 +17,26 @@ module Agents
17 17
 
18 18
     def default_options
19 19
       {
20
-        :client_id => "xxxxxx",
21
-        :client_secret => "xxxxxx",
22
-        :to => "fi",
23
-        :expected_receive_period_in_days => 1,
24
-        :content => {
25
-          :text => "$.message.text",
26
-          :content => "$.xyz"
20
+        'client_id' => "xxxxxx",
21
+        'client_secret' => "xxxxxx",
22
+        'to' => "fi",
23
+        'expected_receive_period_in_days' => 1,
24
+        'content' => {
25
+          'text' => "$.message.text",
26
+          'content' => "$.xyz"
27 27
         }
28 28
       }
29 29
     end
30 30
 
31 31
     def working?
32
-      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
32
+      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
33 33
     end
34 34
 
35 35
     def translate(text, to, access_token)
36 36
       translate_uri = URI 'http://api.microsofttranslator.com/v2/Ajax.svc/Translate'
37 37
       params = {
38
-          :text => text,
39
-          :to => to
38
+        'text' => text,
39
+        'to' => to
40 40
       }
41 41
       translate_uri.query = URI.encode_www_form params
42 42
       request = Net::HTTP::Get.new translate_uri.request_uri
@@ -47,7 +47,7 @@ module Agents
47 47
     end
48 48
 
49 49
     def validate_options
50
-      unless options[:client_id].present? && options[:client_secret].present? && options[:to].present? && options[:content].present? && options[:expected_receive_period_in_days].present?
50
+      unless options['client_id'].present? && options['client_secret'].present? && options['to'].present? && options['content'].present? && options['expected_receive_period_in_days'].present?
51 51
         errors.add :base, "client_id,client_secret,to,expected_receive_period_in_days and content are all required"
52 52
       end
53 53
     end
@@ -60,16 +60,16 @@ module Agents
60 60
 
61 61
     def receive(incoming_events)
62 62
       auth_uri = URI "https://datamarket.accesscontrol.windows.net/v2/OAuth2-13"
63
-      response = postform auth_uri, :client_id => options[:client_id],
64
-                                    :client_secret => options[:client_secret],
63
+      response = postform auth_uri, :client_id => options['client_id'],
64
+                                    :client_secret => options['client_secret'],
65 65
                                     :scope => "http://api.microsofttranslator.com",
66 66
                                     :grant_type => "client_credentials"
67 67
       access_token = JSON.parse(response.body)["access_token"]
68 68
       incoming_events.each do |event|
69 69
         translated_event = {}
70
-        options[:content].each_pair do |key, value|
70
+        options['content'].each_pair do |key, value|
71 71
           to_be_translated = Utils.values_at event.payload, value
72
-          translated_event[key] = translate to_be_translated.first, options[:to], access_token
72
+          translated_event[key] = translate to_be_translated.first, options['to'], access_token
73 73
         end
74 74
         create_event :payload => translated_event
75 75
       end

+ 24 - 24
app/models/agents/trigger_agent.rb

@@ -23,57 +23,57 @@ module Agents
23 23
     MD
24 24
 
25 25
     def validate_options
26
-      unless options[:expected_receive_period_in_days].present? && options[:message].present? && options[:rules].present? &&
27
-          options[:rules].all? { |rule| rule[:type].present? && VALID_COMPARISON_TYPES.include?(rule[:type]) && rule[:value].present? && rule[:path].present? }
26
+      unless options['expected_receive_period_in_days'].present? && options['message'].present? && options['rules'].present? &&
27
+          options['rules'].all? { |rule| rule['type'].present? && VALID_COMPARISON_TYPES.include?(rule['type']) && rule['value'].present? && rule['path'].present? }
28 28
         errors.add(:base, "expected_receive_period_in_days, message, and rules, with a type, value, and path for every rule, are required")
29 29
       end
30 30
     end
31 31
 
32 32
     def default_options
33 33
       {
34
-          :expected_receive_period_in_days => "2",
35
-          :rules => [{
36
-                         :type => "regex",
37
-                         :value => "foo\\d+bar",
38
-                         :path => "topkey.subkey.subkey.goal",
39
-                     }],
40
-          :message => "Looks like your pattern matched in '<value>'!"
34
+        'expected_receive_period_in_days' => "2",
35
+        'rules' => [{
36
+                      'type' => "regex",
37
+                      'value' => "foo\\d+bar",
38
+                      'path' => "topkey.subkey.subkey.goal",
39
+                    }],
40
+        'message' => "Looks like your pattern matched in '<value>'!"
41 41
       }
42 42
     end
43 43
 
44 44
     def working?
45
-      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
45
+      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
46 46
     end
47 47
 
48 48
     def receive(incoming_events)
49 49
       incoming_events.each do |event|
50
-        match = options[:rules].all? do |rule|
51
-          value_at_path = Utils.value_at(event[:payload], rule[:path])
52
-          case rule[:type]
50
+        match = options['rules'].all? do |rule|
51
+          value_at_path = Utils.value_at(event['payload'], rule['path'])
52
+          case rule['type']
53 53
             when "regex"
54
-              value_at_path.to_s =~ Regexp.new(rule[:value], Regexp::IGNORECASE)
54
+              value_at_path.to_s =~ Regexp.new(rule['value'], Regexp::IGNORECASE)
55 55
             when "!regex"
56
-              value_at_path.to_s !~ Regexp.new(rule[:value], Regexp::IGNORECASE)
56
+              value_at_path.to_s !~ Regexp.new(rule['value'], Regexp::IGNORECASE)
57 57
             when "field>value"
58
-              value_at_path.to_f > rule[:value].to_f
58
+              value_at_path.to_f > rule['value'].to_f
59 59
             when "field>=value"
60
-              value_at_path.to_f >= rule[:value].to_f
60
+              value_at_path.to_f >= rule['value'].to_f
61 61
             when "field<value"
62
-              value_at_path.to_f < rule[:value].to_f
62
+              value_at_path.to_f < rule['value'].to_f
63 63
             when "field<=value"
64
-              value_at_path.to_f <= rule[:value].to_f
64
+              value_at_path.to_f <= rule['value'].to_f
65 65
             when "field==value"
66
-              value_at_path.to_s == rule[:value].to_s
66
+              value_at_path.to_s == rule['value'].to_s
67 67
             when "field!=value"
68
-              value_at_path.to_s != rule[:value].to_s
68
+              value_at_path.to_s != rule['value'].to_s
69 69
             else
70
-              raise "Invalid :type of #{rule[:type]} in TriggerAgent##{id}"
70
+              raise "Invalid type of #{rule['type']} in TriggerAgent##{id}"
71 71
           end
72 72
         end
73 73
 
74 74
         if match
75
-          create_event :payload => { :message => make_message(event[:payload]) } # Maybe this should include the
76
-                                                                                 # original event as well?
75
+          create_event :payload => { 'message' => make_message(event[:payload]) } # Maybe this should include the
76
+                                                                                  # original event as well?
77 77
         end
78 78
       end
79 79
     end

+ 25 - 25
app/models/agents/twilio_agent.rb

@@ -9,7 +9,7 @@ module Agents
9 9
     description <<-MD
10 10
       The TwilioAgent receives and collects events and sends them via text message or gives you a call when scheduled.
11 11
 
12
-      It is assumed that events have a `:message`, `:text`, or `:sms` key, the value of which is sent as the content of the text message/call. You can use Event Formatting Agent if your event does not provide these keys.
12
+      It is assumed that events have a `message`, `text`, or `sms` key, the value of which is sent as the content of the text message/call. You can use Event Formatting Agent if your event does not provide these keys.
13 13
 
14 14
       Set `receiver_cell` to the number to receive text messages/call and `sender_cell` to the number sending them.
15 15
 
@@ -22,35 +22,35 @@ module Agents
22 22
 
23 23
     def default_options
24 24
       {
25
-        :account_sid => 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
26
-        :auth_token => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
27
-        :sender_cell => 'xxxxxxxxxx',
28
-        :receiver_cell => 'xxxxxxxxxx',
29
-        :server_url    => 'http://somename.com:3000',
30
-        :receive_text  => 'true',
31
-        :receive_call  => 'false',
32
-        :expected_receive_period_in_days => '1'
25
+        'account_sid' => 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
26
+        'auth_token' => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
27
+        'sender_cell' => 'xxxxxxxxxx',
28
+        'receiver_cell' => 'xxxxxxxxxx',
29
+        'server_url'    => 'http://somename.com:3000',
30
+        'receive_text'  => 'true',
31
+        'receive_call'  => 'false',
32
+        'expected_receive_period_in_days' => '1'
33 33
       }
34 34
     end
35 35
 
36 36
     def validate_options
37
-      unless options[:account_sid].present? && options[:auth_token].present? && options[:sender_cell].present? && options[:receiver_cell].present? && options[:expected_receive_period_in_days].present? && options[:receive_call].present? && options[:receive_text].present?
37
+      unless options['account_sid'].present? && options['auth_token'].present? && options['sender_cell'].present? && options['receiver_cell'].present? && options['expected_receive_period_in_days'].present? && options['receive_call'].present? && options['receive_text'].present?
38 38
         errors.add(:base, 'account_sid, auth_token, sender_cell, receiver_cell, receive_text, receive_call and expected_receive_period_in_days are all required')
39 39
       end
40 40
     end
41 41
 
42 42
     def receive(incoming_events)
43
-      @client = Twilio::REST::Client.new options[:account_sid], options[:auth_token]
44
-      memory[:pending_calls] ||= {}
43
+      @client = Twilio::REST::Client.new options['account_sid'], options['auth_token']
44
+      memory['pending_calls'] ||= {}
45 45
       incoming_events.each do |event|
46
-        message = (event.payload[:message] || event.payload[:text] || event.payload[:sms]).to_s
46
+        message = (event.payload['message'] || event.payload['text'] || event.payload['sms']).to_s
47 47
         if message != ""
48
-          if options[:receive_call].to_s == 'true'
48
+          if options['receive_call'].to_s == 'true'
49 49
             secret = SecureRandom.hex 3
50
-            memory[:pending_calls][secret] = message
50
+            memory['pending_calls'][secret] = message
51 51
             make_call secret
52 52
           end
53
-          if options[:receive_text].to_s == 'true'
53
+          if options['receive_text'].to_s == 'true'
54 54
             message = message.slice 0..160
55 55
             send_message message
56 56
           end
@@ -59,19 +59,19 @@ module Agents
59 59
     end
60 60
 
61 61
     def working?
62
-      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
62
+      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
63 63
     end
64 64
 
65 65
     def send_message(message)
66
-      @client.account.sms.messages.create :from => options[:sender_cell],
67
-                                          :to => options[:receiver_cell],
66
+      @client.account.sms.messages.create :from => options['sender_cell'],
67
+                                          :to => options['receiver_cell'],
68 68
                                           :body => message
69 69
     end
70 70
 
71 71
     def make_call(secret)
72
-      @client.account.calls.create :from => options[:sender_cell],
73
-                                   :to => options[:receiver_cell],
74
-                                   :url => post_url(options[:server_url],secret)
72
+      @client.account.calls.create :from => options['sender_cell'],
73
+                                   :to => options['receiver_cell'],
74
+                                   :url => post_url(options['server_url'],secret)
75 75
     end
76 76
 
77 77
     def post_url(server_url,secret)
@@ -79,9 +79,9 @@ module Agents
79 79
     end
80 80
 
81 81
     def receive_webhook(params)
82
-      if memory[:pending_calls].has_key? params[:secret].to_sym
83
-        response = Twilio::TwiML::Response.new {|r| r.Say memory[:pending_calls][params[:secret].to_sym], :voice => 'woman'}
84
-        memory[:pending_calls].delete params[:secret].to_sym
82
+      if memory['pending_calls'].has_key? params['secret']
83
+        response = Twilio::TwiML::Response.new {|r| r.Say memory['pending_calls'][params['secret']], :voice => 'woman'}
84
+        memory['pending_calls'].delete params['secret']
85 85
         [response.text, 200]
86 86
       end
87 87
     end

+ 20 - 20
app/models/agents/twitter_publish_agent.rb

@@ -19,25 +19,25 @@ module Agents
19 19
     MD
20 20
 
21 21
     def validate_options
22
-      unless options[:username].present? &&
23
-        options[:expected_update_period_in_days].present?
22
+      unless options['username'].present? &&
23
+        options['expected_update_period_in_days'].present?
24 24
         errors.add(:base, "username and expected_update_period_in_days are required")
25 25
       end      
26 26
     end
27 27
 
28 28
     def working?
29
-      (event = event_created_within(options[:expected_update_period_in_days])) && event.payload[:success] == true && !recent_error_logs?
29
+      event_created_within?(options['expected_update_period_in_days']) && most_recent_event.payload['success'] == true && !recent_error_logs?
30 30
     end
31 31
 
32 32
     def default_options
33 33
       {
34
-          :username => "",
35
-          :expected_update_period_in_days => "10",
36
-          :consumer_key => "---",
37
-          :consumer_secret => "---",
38
-          :oauth_token => "---",
39
-          :oauth_token_secret => "---",
40
-          :message_path => "text"
34
+        'username' => "",
35
+        'expected_update_period_in_days' => "10",
36
+        'consumer_key' => "---",
37
+        'consumer_secret' => "---",
38
+        'oauth_token' => "---",
39
+        'oauth_token_secret' => "---",
40
+        'message_path' => "text"
41 41
       }
42 42
     end
43 43
 
@@ -47,22 +47,22 @@ module Agents
47 47
         incoming_events = incoming_events.first(20)
48 48
       end
49 49
       incoming_events.each do |event|
50
-        tweet_text = Utils.value_at(event.payload, options[:message_path])
50
+        tweet_text = Utils.value_at(event.payload, options['message_path'])
51 51
         begin
52 52
           publish_tweet tweet_text
53 53
           create_event :payload => {
54
-            :success => true,
55
-            :published_tweet => tweet_text,
56
-            :agent_id => event.agent_id,
57
-            :event_id => event.id
54
+            'success' => true,
55
+            'published_tweet' => tweet_text,
56
+            'agent_id' => event.agent_id,
57
+            'event_id' => event.id
58 58
           }
59 59
         rescue Twitter::Error => e
60 60
           create_event :payload => {
61
-            :success => false,
62
-            :error => e.message,
63
-            :failed_tweet => tweet_text,
64
-            :agent_id => event.agent_id,
65
-            :event_id => event.id
61
+            'success' => false,
62
+            'error' => e.message,
63
+            'failed_tweet' => tweet_text,
64
+            'agent_id' => event.agent_id,
65
+            'event_id' => event.id
66 66
           }
67 67
         end
68 68
       end

+ 23 - 23
app/models/agents/twitter_stream_agent.rb

@@ -54,26 +54,26 @@ module Agents
54 54
     default_schedule "11pm"
55 55
 
56 56
     def validate_options
57
-      unless options[:filters].present? &&
58
-             options[:expected_update_period_in_days].present? &&
59
-             options[:generate].present?
57
+      unless options['filters'].present? &&
58
+             options['expected_update_period_in_days'].present? &&
59
+             options['generate'].present?
60 60
         errors.add(:base, "expected_update_period_in_days, generate, and filters are required fields")
61 61
       end
62 62
     end
63 63
 
64 64
     def working?
65
-      event_created_within(options[:expected_update_period_in_days]) && !recent_error_logs?
65
+      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
66 66
     end
67 67
 
68 68
     def default_options
69 69
       {
70
-          :consumer_key => "---",
71
-          :consumer_secret => "---",
72
-          :oauth_token => "---",
73
-          :oauth_token_secret => "---",
74
-          :filters => %w[keyword1 keyword2],
75
-          :expected_update_period_in_days => "2",
76
-          :generate => "events"
70
+          'consumer_key' => "---",
71
+          'consumer_secret' => "---",
72
+          'oauth_token' => "---",
73
+          'oauth_token_secret' => "---",
74
+          'filters' => %w[keyword1 keyword2],
75
+          'expected_update_period_in_days' => "2",
76
+          'generate' => "events"
77 77
       }
78 78
     end
79 79
 
@@ -81,33 +81,33 @@ module Agents
81 81
       filter = lookup_filter(filter)
82 82
 
83 83
       if filter
84
-        if options[:generate] == "counts"
84
+        if options['generate'] == "counts"
85 85
           # Avoid memory pollution by reloading the Agent.
86 86
           agent = Agent.find(id)
87
-          agent.memory[:filter_counts] ||= {}
88
-          agent.memory[:filter_counts][filter.to_sym] ||= 0
89
-          agent.memory[:filter_counts][filter.to_sym] += 1
90
-          remove_unused_keys!(agent, :filter_counts)
87
+          agent.memory['filter_counts'] ||= {}
88
+          agent.memory['filter_counts'][filter] ||= 0
89
+          agent.memory['filter_counts'][filter] += 1
90
+          remove_unused_keys!(agent, 'filter_counts')
91 91
           agent.save!
92 92
         else
93
-          create_event :payload => status.merge(:filter => filter.to_s)
93
+          create_event :payload => status.merge('filter' => filter)
94 94
         end
95 95
       end
96 96
     end
97 97
 
98 98
     def check
99
-      if options[:generate] == "counts" && memory[:filter_counts] && memory[:filter_counts].length > 0
100
-        memory[:filter_counts].each do |filter, count|
101
-          create_event :payload => { :filter => filter.to_s, :count => count, :time => Time.now.to_i }
99
+      if options['generate'] == "counts" && memory['filter_counts'] && memory['filter_counts'].length > 0
100
+        memory['filter_counts'].each do |filter, count|
101
+          create_event :payload => { 'filter' => filter, 'count' => count, 'time' => Time.now.to_i }
102 102
         end
103 103
       end
104
-      memory[:filter_counts] = {}
104
+      memory['filter_counts'] = {}
105 105
     end
106 106
 
107 107
     protected
108 108
 
109 109
     def lookup_filter(filter)
110
-      options[:filters].each do |known_filter|
110
+      options['filters'].each do |known_filter|
111 111
         if known_filter == filter
112 112
           return filter
113 113
         elsif known_filter.is_a?(Array)
@@ -120,7 +120,7 @@ module Agents
120 120
 
121 121
     def remove_unused_keys!(agent, base)
122 122
       if agent.memory[base]
123
-        (agent.memory[base].keys - agent.options[:filters].map {|f| f.is_a?(Array) ? f.first.to_sym : f.to_sym }).each do |removed_key|
123
+        (agent.memory[base].keys - agent.options['filters'].map {|f| f.is_a?(Array) ? f.first.to_s : f.to_s }).each do |removed_key|
124 124
           agent.memory[base].delete(removed_key)
125 125
         end
126 126
       end

+ 12 - 12
app/models/agents/twitter_user_agent.rb

@@ -41,36 +41,36 @@ module Agents
41 41
     default_schedule "every_1h"
42 42
 
43 43
     def validate_options
44
-      unless options[:username].present? &&
45
-        options[:expected_update_period_in_days].present?
44
+      unless options['username'].present? &&
45
+        options['expected_update_period_in_days'].present?
46 46
         errors.add(:base, "username and expected_update_period_in_days are required")
47 47
       end      
48 48
     end
49 49
 
50 50
     def working?
51
-      event_created_within(options[:expected_update_period_in_days]) && !recent_error_logs?
51
+      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
52 52
     end
53 53
 
54 54
     def default_options
55 55
       {
56
-          :username => "tectonic",
57
-          :expected_update_period_in_days => "2",
58
-          :consumer_key => "---",
59
-          :consumer_secret => "---",
60
-          :oauth_token => "---",
61
-          :oauth_token_secret => "---"
56
+          'username' => "tectonic",
57
+          'expected_update_period_in_days' => "2",
58
+          'consumer_key' => "---",
59
+          'consumer_secret' => "---",
60
+          'oauth_token' => "---",
61
+          'oauth_token_secret' => "---"
62 62
       }
63 63
     end
64 64
 
65 65
     def check
66
-      since_id = memory[:since_id] || nil
66
+      since_id = memory['since_id'] || nil
67 67
       opts = {:count => 200, :include_rts => true, :exclude_replies => false, :include_entities => true, :contributor_details => true}
68 68
       opts.merge! :since_id => since_id unless since_id.nil?
69 69
 
70
-      tweets = Twitter.user_timeline(options[:username], opts)
70
+      tweets = Twitter.user_timeline(options['username'], opts)
71 71
 
72 72
       tweets.each do |tweet|
73
-        memory[:since_id] = tweet.id if !memory[:since_id] || (tweet.id > memory[:since_id])
73
+        memory['since_id'] = tweet.id if !memory['since_id'] || (tweet.id > memory['since_id'])
74 74
 
75 75
         create_event :payload => tweet.attrs
76 76
       end

+ 3 - 3
app/models/agents/user_location_agent.rb

@@ -30,15 +30,15 @@ module Agents
30 30
     MD
31 31
 
32 32
     def working?
33
-      event_created_within(2) && !recent_error_logs?
33
+      event_created_within?(2) && !recent_error_logs?
34 34
     end
35 35
 
36 36
     def default_options
37
-      { :secret => SecureRandom.hex(7) }
37
+      { 'secret' => SecureRandom.hex(7) }
38 38
     end
39 39
 
40 40
     def validate_options
41
-      errors.add(:base, "secret is required and must be longer than 4 characters") unless options[:secret].present? && options[:secret].length > 4
41
+      errors.add(:base, "secret is required and must be longer than 4 characters") unless options['secret'].present? && options['secret'].length > 4
42 42
     end
43 43
   end
44 44
 end

+ 9 - 9
app/models/agents/weather_agent.rb

@@ -41,34 +41,34 @@ module Agents
41 41
     default_schedule "8pm"
42 42
 
43 43
     def working?
44
-      event_created_within(2) && !recent_error_logs?
44
+      event_created_within?(2) && !recent_error_logs?
45 45
     end
46 46
 
47 47
     def wunderground
48
-      Wunderground.new(options[:api_key]) if key_setup?
48
+      Wunderground.new(options['api_key']) if key_setup?
49 49
     end
50 50
 
51 51
     def key_setup?
52
-      options[:api_key] && options[:api_key] != "your-key"
52
+      options['api_key'] && options['api_key'] != "your-key"
53 53
     end
54 54
 
55 55
     def default_options
56 56
       {
57
-        :api_key => "your-key",
58
-        :location => "94103"
57
+        'api_key' => "your-key",
58
+        'location' => "94103"
59 59
       }
60 60
     end
61 61
 
62 62
     def validate_options
63
-      errors.add(:base, "location is required") unless options[:location].present? || options[:zipcode].present?
64
-      errors.add(:base, "api_key is required") unless options[:api_key].present?
63
+      errors.add(:base, "location is required") unless options['location'].present? || options['zipcode'].present?
64
+      errors.add(:base, "api_key is required") unless options['api_key'].present?
65 65
     end
66 66
 
67 67
     def check
68 68
       if key_setup?
69
-        wunderground.forecast_for(options[:location] || options[:zipcode])["forecast"]["simpleforecast"]["forecastday"].each do |day|
69
+        wunderground.forecast_for(options['location'] || options['zipcode'])["forecast"]["simpleforecast"]["forecastday"].each do |day|
70 70
           if is_tomorrow?(day)
71
-            create_event :payload => day.merge(:location => options[:location] || options[:zipcode])
71
+            create_event :payload => day.merge('location' => options['location'] || options['zipcode'])
72 72
           end
73 73
         end
74 74
       end

+ 62 - 0
app/models/agents/webhook_agent.rb

@@ -0,0 +1,62 @@
1
+module Agents
2
+  class WebhookAgent < Agent
3
+    cannot_be_scheduled!
4
+
5
+    description  do
6
+        <<-MD
7
+        Use this Agent to create events by receiving webhooks from any source.
8
+
9
+        In order to create events with this agent, make a POST request to:
10
+        ```
11
+           https://#{ENV['DOMAIN']}/users/#{user.id}/webhooks/#{id || '<id>'}/:secret
12
+        ``` where `:secret` is specified in your options.
13
+
14
+        The
15
+
16
+        Options:
17
+
18
+          * `secret` - A token that the host will provide for authentication.
19
+          * `expected_receive_period_in_days` - How often you expect to receive
20
+            events this way. Used to determine if the agent is working.
21
+          * `payload_path` - JSONPath of the attribute in the POST body to be
22
+            used as the Event payload.
23
+      MD
24
+    end
25
+
26
+    event_description do
27
+      <<-MD
28
+        The event payload is base on the value of the `payload_path` option,
29
+        which is set to `#{options['payload_path']}`.
30
+      MD
31
+    end
32
+
33
+    def default_options
34
+      { "secret" => "supersecretstring",
35
+        "expected_receive_period_in_days" => 1,
36
+        "payload_path" => "payload"}
37
+    end
38
+
39
+    def receive_webhook(params)
40
+      secret = params.delete('secret')
41
+      return ["Not Authorized", 401] unless secret == options['secret']
42
+
43
+      create_event(:payload => payload_for(params))
44
+
45
+      ['Event Created', 201]
46
+    end
47
+
48
+    def working?
49
+      event_created_within(options['expected_receive_period_in_days']) && !recent_error_logs?
50
+    end
51
+
52
+    def validate_options
53
+      unless options['secret'].present?
54
+        errors.add(:base, "Must specify a secret for 'Authenticating' requests")
55
+      end
56
+    end
57
+
58
+    def payload_for(params)
59
+      Utils.value_at(params, options['payload_path']) || {}
60
+    end
61
+  end
62
+end

+ 39 - 39
app/models/agents/website_agent.rb

@@ -15,19 +15,19 @@ module Agents
15 15
 
16 16
       To tell the Agent how to parse the content, specify `extract` as a hash with keys naming the extractions and values of hashes.
17 17
 
18
-      When parsing HTML or XML, these sub-hashes specify how to extract with a `:css` CSS selector and either `:text => true` or `attr` pointing to an attribute name to grab.  An example:
18
+      When parsing HTML or XML, these sub-hashes specify how to extract with a `css` CSS selector and either `'text': true` or `attr` pointing to an attribute name to grab.  An example:
19 19
 
20
-          :extract => {
21
-            :url => { :css => "#comic img", :attr => "src" },
22
-            :title => { :css => "#comic img", :attr => "title" },
23
-            :body_text => { :css => "div.main", :text => true }
20
+          'extract': {
21
+            'url': { 'css': "#comic img", 'attr': "src" },
22
+            'title': { 'css': "#comic img", 'attr': "title" },
23
+            'body_text': { 'css': "div.main", 'text': true }
24 24
           }
25 25
 
26 26
       When parsing JSON, these sub-hashes specify [JSONPaths](http://goessner.net/articles/JsonPath/) to the values that you care about.  For example:
27 27
 
28
-          :extract => {
29
-            :title => { :path => "results.data[*].title" },
30
-            :description => { :path => "results.data[*].description" }
28
+          'extract': {
29
+            'title': { 'path': "results.data[*].title" },
30
+            'description': { 'path': "results.data[*].description" }
31 31
           }
32 32
 
33 33
       Note that for all of the formats, whatever you extract MUST have the same number of matches for each extractor.  E.g., if you're extracting rows, all extractors must match all rows.  For generating CSS selectors, something like [SelectorGadget](http://selectorgadget.com) may be helpful.
@@ -36,7 +36,7 @@ module Agents
36 36
     MD
37 37
 
38 38
     event_description do
39
-      "Events will have the fields you specified.  Your options look like:\n\n    #{Utils.pretty_print options[:extract]}"
39
+      "Events will have the fields you specified.  Your options look like:\n\n    #{Utils.pretty_print options['extract']}"
40 40
     end
41 41
 
42 42
     default_schedule "every_12h"
@@ -44,33 +44,33 @@ module Agents
44 44
     UNIQUENESS_LOOK_BACK = 30
45 45
 
46 46
     def working?
47
-      event_created_within(options[:expected_update_period_in_days]) && !recent_error_logs?
47
+      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
48 48
     end
49 49
 
50 50
     def default_options
51 51
       {
52
-          :expected_update_period_in_days => "2",
53
-          :url => "http://xkcd.com",
54
-          :type => "html",
55
-          :mode => :on_change,
56
-          :extract => {
57
-              :url => {:css => "#comic img", :attr => "src"},
58
-              :title => {:css => "#comic img", :attr => "title"}
52
+          'expected_update_period_in_days' => "2",
53
+          'url' => "http://xkcd.com",
54
+          'type' => "html",
55
+          'mode' => :on_change,
56
+          'extract' => {
57
+            'url' => {'css' => "#comic img", 'attr' => "src"},
58
+            'title' => {'css' => "#comic img", 'attr' => "title"}
59 59
           }
60 60
       }
61 61
     end
62 62
 
63 63
     def validate_options
64
-      errors.add(:base, "url and expected_update_period_in_days are required") unless options[:expected_update_period_in_days].present? && options[:url].present?
65
-      if !options[:extract].present? && extraction_type != "json"
64
+      errors.add(:base, "url and expected_update_period_in_days are required") unless options['expected_update_period_in_days'].present? && options['url'].present?
65
+      if !options['extract'].present? && extraction_type != "json"
66 66
         errors.add(:base, "extract is required for all types except json")
67 67
       end
68 68
     end
69 69
 
70 70
     def check
71 71
       hydra = Typhoeus::Hydra.new
72
-      log "Fetching #{options[:url]}"
73
-      request = Typhoeus::Request.new(options[:url], :followlocation => true)
72
+      log "Fetching #{options['url']}"
73
+      request = Typhoeus::Request.new(options['url'], :followlocation => true)
74 74
       request.on_failure do |response|
75 75
         error "Failed: #{response.inspect}"
76 76
       end
@@ -85,37 +85,37 @@ module Agents
85 85
           end
86 86
         else
87 87
           output = {}
88
-          options[:extract].each do |name, extraction_details|
88
+          options['extract'].each do |name, extraction_details|
89 89
             result = if extraction_type == "json"
90
-                       output[name] = Utils.values_at(doc, extraction_details[:path])
90
+                       output[name] = Utils.values_at(doc, extraction_details['path'])
91 91
                      else
92
-                       output[name] = doc.css(extraction_details[:css]).map { |node|
93
-                         if extraction_details[:attr]
94
-                           node.attr(extraction_details[:attr])
95
-                         elsif extraction_details[:text]
92
+                       output[name] = doc.css(extraction_details['css']).map { |node|
93
+                         if extraction_details['attr']
94
+                           node.attr(extraction_details['attr'])
95
+                         elsif extraction_details['text']
96 96
                            node.text()
97 97
                          else
98
-                           error ":attr or :text is required on HTML or XML extraction patterns"
98
+                           error "'attr' or 'text' is required on HTML or XML extraction patterns"
99 99
                            return
100 100
                          end
101 101
                        }
102 102
                      end
103
-            log "Extracting #{extraction_type} at #{extraction_details[:path] || extraction_details[:css]}: #{result}"
103
+            log "Extracting #{extraction_type} at #{extraction_details['path'] || extraction_details['css']}: #{result}"
104 104
           end
105 105
 
106
-          num_unique_lengths = options[:extract].keys.map { |name| output[name].length }.uniq
106
+          num_unique_lengths = options['extract'].keys.map { |name| output[name].length }.uniq
107 107
 
108 108
           if num_unique_lengths.length != 1
109
-            error "Got an uneven number of matches for #{options[:name]}: #{options[:extract].inspect}"
109
+            error "Got an uneven number of matches for #{options['name']}: #{options['extract'].inspect}"
110 110
             return
111 111
           end
112 112
       
113 113
           num_unique_lengths.first.times do |index|
114 114
             result = {}
115
-            options[:extract].keys.each do |name|
115
+            options['extract'].keys.each do |name|
116 116
               result[name] = output[name][index]
117 117
               if name.to_s == 'url'
118
-                result[name] = URI.join(options[:url], result[name]).to_s if (result[name] =~ URI::DEFAULT_PARSER.regexp[:ABS_URI]).nil?
118
+                result[name] = URI.join(options['url'], result[name]).to_s if (result[name] =~ URI::DEFAULT_PARSER.regexp[:ABS_URI]).nil?
119 119
               end
120 120
             end
121 121
 
@@ -133,22 +133,22 @@ module Agents
133 133
     private
134 134
 
135 135
     def store_payload? result
136
-      !options[:mode] || options[:mode].to_s == "all" || (options[:mode].to_s == "on_change" && !previous_payloads.include?(result.to_json))
136
+      !options['mode'] || options['mode'].to_s == "all" || (options['mode'].to_s == "on_change" && !previous_payloads.include?(result.to_json))
137 137
     end
138 138
 
139 139
     def previous_payloads
140
-      events.order("id desc").limit(UNIQUENESS_LOOK_BACK).pluck(:payload).map(&:to_json) if options[:mode].to_s == "on_change"
140
+      events.order("id desc").limit(UNIQUENESS_LOOK_BACK).pluck(:payload).map(&:to_json) if options['mode'].to_s == "on_change"
141 141
     end
142 142
 
143 143
     def extract_full_json?
144
-      (!options[:extract].present? && extraction_type == "json")
144
+      (!options['extract'].present? && extraction_type == "json")
145 145
     end
146 146
 
147 147
     def extraction_type
148
-      (options[:type] || begin
149
-        if options[:url] =~ /\.(rss|xml)$/i
148
+      (options['type'] || begin
149
+        if options['url'] =~ /\.(rss|xml)$/i
150 150
           "xml"
151
-        elsif options[:url] =~ /\.json$/i
151
+        elsif options['url'] =~ /\.json$/i
152 152
           "json"
153 153
         else
154 154
           "html"

+ 19 - 19
app/models/agents/weibo_publish_agent.rb

@@ -20,24 +20,24 @@ module Agents
20 20
     MD
21 21
 
22 22
     def validate_options
23
-      unless options[:uid].present? &&
24
-        options[:expected_update_period_in_days].present?
23
+      unless options['uid'].present? &&
24
+        options['expected_update_period_in_days'].present?
25 25
         errors.add(:base, "expected_update_period_in_days and uid are required")
26 26
       end
27 27
     end
28 28
 
29 29
     def working?
30
-      (event = event_created_within(options[:expected_update_period_in_days])) && event.payload[:success] == true && !recent_error_logs?
30
+      event_created_within?(options['expected_update_period_in_days']) && most_recent_event.payload['success'] == true && !recent_error_logs?
31 31
     end
32 32
 
33 33
     def default_options
34 34
       {
35
-          :uid => "",
36
-          :access_token => "---",
37
-          :app_key => "---",
38
-          :app_secret => "---",
39
-          :expected_update_period_in_days => "10",
40
-          :message_path => "text"
35
+        'uid' => "",
36
+        'access_token' => "---",
37
+        'app_key' => "---",
38
+        'app_secret' => "---",
39
+        'expected_update_period_in_days' => "10",
40
+        'message_path' => "text"
41 41
       }
42 42
     end
43 43
 
@@ -47,25 +47,25 @@ module Agents
47 47
         incoming_events = incoming_events.first(20)
48 48
       end
49 49
       incoming_events.each do |event|
50
-        tweet_text = Utils.value_at(event.payload, options[:message_path])
50
+        tweet_text = Utils.value_at(event.payload, options['message_path'])
51 51
         if event.agent.type == "Agents::TwitterUserAgent"
52 52
           tweet_text = unwrap_tco_urls(tweet_text, event.payload)
53 53
         end
54 54
         begin
55 55
           publish_tweet tweet_text
56 56
           create_event :payload => {
57
-            :success => true,
58
-            :published_tweet => tweet_text,
59
-            :agent_id => event.agent_id,
60
-            :event_id => event.id
57
+            'success' => true,
58
+            'published_tweet' => tweet_text,
59
+            'agent_id' => event.agent_id,
60
+            'event_id' => event.id
61 61
           }
62 62
         rescue OAuth2::Error => e
63 63
           create_event :payload => {
64
-            :success => false,
65
-            :error => e.message,
66
-            :failed_tweet => tweet_text,
67
-            :agent_id => event.agent_id,
68
-            :event_id => event.id
64
+            'success' => false,
65
+            'error' => e.message,
66
+            'failed_tweet' => tweet_text,
67
+            'agent_id' => event.agent_id,
68
+            'event_id' => event.id
69 69
           }
70 70
         end
71 71
       end

+ 11 - 11
app/models/agents/weibo_user_agent.rb

@@ -70,29 +70,29 @@ module Agents
70 70
     default_schedule "every_1h"
71 71
 
72 72
     def validate_options
73
-      unless options[:uid].present? &&
74
-        options[:expected_update_period_in_days].present?
73
+      unless options['uid'].present? &&
74
+        options['expected_update_period_in_days'].present?
75 75
         errors.add(:base, "expected_update_period_in_days and uid are required")
76 76
       end
77 77
     end
78 78
 
79 79
     def working?
80
-      event_created_within(options[:expected_update_period_in_days]) && !recent_error_logs?
80
+      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
81 81
     end
82 82
 
83 83
     def default_options
84 84
       {
85
-          :uid => "",
86
-          :access_token => "---",
87
-          :app_key => "---",
88
-          :app_secret => "---",
89
-          :expected_update_period_in_days => "2"
85
+        'uid' => "",
86
+        'access_token' => "---",
87
+        'app_key' => "---",
88
+        'app_secret' => "---",
89
+        'expected_update_period_in_days' => "2"
90 90
       }
91 91
     end
92 92
 
93 93
     def check
94
-      since_id = memory[:since_id] || nil
95
-      opts = {:uid => options[:uid].to_i}
94
+      since_id = memory['since_id'] || nil
95
+      opts = {:uid => options['uid'].to_i}
96 96
       opts.merge! :since_id => since_id unless since_id.nil?
97 97
 
98 98
       # http://open.weibo.com/wiki/2/statuses/user_timeline/en
@@ -101,7 +101,7 @@ module Agents
101 101
 
102 102
 
103 103
         resp[:statuses].each do |status|
104
-          memory[:since_id] = status.id if !memory[:since_id] || (status.id > memory[:since_id])
104
+          memory['since_id'] = status.id if !memory['since_id'] || (status.id > memory['since_id'])
105 105
 
106 106
           create_event :payload => status.as_json
107 107
         end

+ 6 - 8
app/models/event.rb

@@ -1,23 +1,21 @@
1
+require 'json_serialized_field'
2
+
1 3
 class Event < ActiveRecord::Base
4
+  include JSONSerializedField
5
+
2 6
   attr_accessible :lat, :lng, :payload, :user_id, :user, :expires_at
3 7
 
4 8
   acts_as_mappable
5 9
 
6
-  serialize :payload
10
+  json_serialize :payload
7 11
 
8 12
   belongs_to :user
9
-  belongs_to :agent, :counter_cache => true
10
-
11
-  before_save :symbolize_payload
13
+  belongs_to :agent, :counter_cache => true, :touch => :last_event_at
12 14
 
13 15
   scope :recent, lambda { |timespan = 12.hours.ago|
14 16
     where("events.created_at > ?", timespan)
15 17
   }
16 18
 
17
-  def symbolize_payload
18
-    self.payload = payload.recursively_symbolize_keys if payload.is_a?(Hash)
19
-  end
20
-
21 19
   def reemit!
22 20
     agent.create_event :payload => payload, :lat => lat, :lng => lng
23 21
   end

+ 1 - 1
app/views/agents/show.html.erb

@@ -11,7 +11,7 @@
11 11
             <li class='disabled'><a><i class='icon-picture'></i> Summary</a></li>
12 12
             <li class='active'><a href="#details" data-toggle="tab"><i class='icon-indent-left'></i> Details</a></li>
13 13
           <% end %>
14
-          <li><a href="#logs" data-toggle="tab" data-agent-id="<%= @agent.id %>"><i class='icon-list-alt'></i> Logs</a></li>
14
+          <li><a href="#logs" data-toggle="tab" data-agent-id="<%= @agent.id %>" class='<%= @agent.recent_error_logs? ? 'recent-errors' : '' %>'><i class='icon-list-alt'></i> Logs</a></li>
15 15
 
16 16
           <% if @agent.can_create_events? && @agent.events.count > 0 %>
17 17
             <li><%= link_to '<i class="icon-random"></i> Events'.html_safe, events_path(:agent => @agent.to_param) %></li>

+ 1 - 1
app/views/layouts/application.html.erb

@@ -36,7 +36,7 @@
36 36
     <script>
37 37
       var agentPaths = {};
38 38
       <% if current_user -%>
39
-        var myAgents = <%= Utils.jsonify(current_user.agents.inject({}) {|m, a| m[a.name] = agent_path(a) unless a.new_record?; m }) %>;
39
+        var myAgents = <%= Utils.jsonify(current_user.agents.select([:name, :id, :schedule]).inject({}) {|m, a| m[a.name] = agent_path(a); m }) %>;
40 40
         $.extend(agentPaths, myAgents);
41 41
       <% end -%>
42 42
       agentPaths["All Agents Index"] = <%= Utils.jsonify agents_path %>;

+ 1 - 1
config/initializers/delayed_job.rb

@@ -1,4 +1,4 @@
1
-Delayed::Worker.destroy_failed_jobs = false
1
+Delayed::Worker.destroy_failed_jobs = true
2 2
 Delayed::Worker.max_attempts = 5
3 3
 Delayed::Worker.max_run_time = 20.minutes
4 4
 Delayed::Worker.default_priority = 10

+ 0 - 7
config/initializers/recursively_symbolize_keys.rb

@@ -1,7 +0,0 @@
1
-require 'utils'
2
-
3
-class Hash
4
-  def recursively_symbolize_keys
5
-    Utils.recursively_symbolize_keys self
6
-  end
7
-end

+ 67 - 0
db/migrate/20131223032112_switch_to_json_serialization.rb

@@ -0,0 +1,67 @@
1
+class SwitchToJsonSerialization < ActiveRecord::Migration
2
+  FIELDS = {
3
+    :agents => [:options, :memory],
4
+    :events => [:payload]
5
+  }
6
+
7
+  def up
8
+    if data_exists?
9
+      puts "This migration will update tables to use UTF-8 encoding and will update Agent and Event storage from YAML to JSON."
10
+      puts "It should work, but please make a backup before proceeding!"
11
+      print "Continue? (y/n) "
12
+      STDOUT.flush
13
+      exit unless STDIN.gets =~ /^y/i
14
+
15
+      set_to_utf8
16
+      translate YAML, JSON
17
+    end
18
+  end
19
+
20
+  def down
21
+    if data_exists?
22
+      translate JSON, YAML
23
+    end
24
+  end
25
+
26
+  def set_to_utf8
27
+    if mysql?
28
+      %w[agent_logs agents delayed_jobs events links users].each do |table_name|
29
+        quoted_table_name = ActiveRecord::Base.connection.quote_table_name(table_name)
30
+        execute "ALTER TABLE #{quoted_table_name} CONVERT TO CHARACTER SET utf8"
31
+      end
32
+    end
33
+  end
34
+
35
+  def mysql?
36
+    ActiveRecord::Base.connection.adapter_name =~ /mysql/i
37
+  end
38
+
39
+  def data_exists?
40
+    events = ActiveRecord::Base.connection.select_rows("SELECT count(*) FROM #{ActiveRecord::Base.connection.quote_table_name("events")}").first.first
41
+    agents = ActiveRecord::Base.connection.select_rows("SELECT count(*) FROM #{ActiveRecord::Base.connection.quote_table_name("agents")}").first.first
42
+    agents + events > 0
43
+  end
44
+
45
+  def translate(from, to)
46
+    FIELDS.each do |table, fields|
47
+      quoted_table_name = ActiveRecord::Base.connection.quote_table_name(table)
48
+      fields = fields.map { |f| ActiveRecord::Base.connection.quote_column_name(f) }
49
+
50
+      rows = ActiveRecord::Base.connection.select_rows("SELECT id, #{fields.join(", ")} FROM #{quoted_table_name}")
51
+      rows.each do |row|
52
+        id, *field_data = row
53
+
54
+        yaml_fields = field_data.map { |f| from.load(f) }.map { |f| to.dump(f) }
55
+
56
+        yaml_fields.map! {|f| f.encode('utf-8', 'binary', invalid: :replace, undef: :replace, replace: '??') }
57
+
58
+        update_sql = "UPDATE #{quoted_table_name} SET #{fields.map {|f| "#{f}=?"}.join(", ")} WHERE id = ?"
59
+
60
+        sanitized_update_sql = ActiveRecord::Base.send :sanitize_sql_array, [update_sql, *yaml_fields, id]
61
+
62
+        ActiveRecord::Base.connection.execute sanitized_update_sql
63
+      end
64
+    end
65
+
66
+  end
67
+end

+ 14 - 0
db/migrate/20131227000021_add_cached_dates_to_agent.rb

@@ -0,0 +1,14 @@
1
+class AddCachedDatesToAgent < ActiveRecord::Migration
2
+  def up
3
+    add_column :agents, :last_event_at, :datetime
4
+    execute "UPDATE agents SET last_event_at = (SELECT created_at FROM events WHERE events.agent_id = agents.id ORDER BY id DESC LIMIT 1)"
5
+
6
+    add_column :agents, :last_error_log_at, :datetime
7
+    execute "UPDATE agents SET last_error_log_at = (SELECT created_at FROM agent_logs WHERE agent_logs.agent_id = agents.id AND agent_logs.level >= 4 ORDER BY id DESC LIMIT 1)"
8
+  end
9
+
10
+  def down
11
+    remove_column :agents, :last_event_at
12
+    remove_column :agents, :last_error_log_at
13
+  end
14
+end

+ 1 - 1
doc/deployment/backup/example_backup.rb

@@ -55,6 +55,6 @@ Backup::Model.new(:huginn_backup, 'The Huginn backup configuration') do
55 55
     mail.password             = 'password'
56 56
     mail.port                 =  587
57 57
     mail.authentication       = "plain"
58
-    mail.enable_starttls_auto = true
58
+    mail.encryption           = :starttls
59 59
   end
60 60
 end

+ 38 - 2
lib/capistrano/sync.rb

@@ -1,5 +1,6 @@
1 1
 require 'yaml'
2 2
 require 'pathname'
3
+require 'dotenv'
3 4
 
4 5
 # Edited by Andrew Cantino.  Based on: https://gist.github.com/339471
5 6
 
@@ -89,13 +90,42 @@ namespace :sync do
89 90
     end
90 91
   end
91 92
 
93
+  # Used by database_config and remote_database_config to parse database configs that depend on .env files.  Depends on the dotenv-rails gem.
94
+  class EnvLoader < Dotenv::Environment
95
+    def initialize(data)
96
+      @data = data
97
+      load
98
+    end
99
+
100
+    def with_loaded_env
101
+      begin
102
+        saved_env = ENV.to_hash.dup
103
+        ENV.update(self)
104
+        yield
105
+      ensure
106
+        ENV.replace(saved_env)
107
+      end
108
+    end
109
+
110
+    def read
111
+      @data.split("\n")
112
+    end
113
+  end
114
+
92 115
   #
93 116
   # Reads the database credentials from the local config/database.yml file
94 117
   # +db+ the name of the environment to get the credentials for
95 118
   # Returns username, password, database
96 119
   #
97 120
   def database_config(db)
98
-    database = YAML::load_file('config/database.yml')
121
+    local_config = File.read('config/database.yml')
122
+    local_env = File.read('.env')
123
+
124
+    database = nil
125
+    EnvLoader.new(local_env).with_loaded_env do
126
+      database = YAML::load(ERB.new(local_config).result)
127
+    end
128
+
99 129
     return database["#{db}"]['username'], database["#{db}"]['password'], database["#{db}"]['database'], database["#{db}"]['host']
100 130
   end
101 131
 
@@ -106,7 +136,13 @@ namespace :sync do
106 136
   #
107 137
   def remote_database_config(db)
108 138
     remote_config = capture("cat #{current_path}/config/database.yml")
109
-    database = YAML::load(remote_config)
139
+    remote_env = capture("cat #{current_path}/.env")
140
+
141
+    database = nil
142
+    EnvLoader.new(remote_env).with_loaded_env do
143
+      database = YAML::load(ERB.new(remote_config).result)
144
+    end
145
+
110 146
     return database["#{db}"]['username'], database["#{db}"]['password'], database["#{db}"]['database'], database["#{db}"]['host']
111 147
   end
112 148
 

+ 42 - 0
lib/json_serialized_field.rb

@@ -0,0 +1,42 @@
1
+require 'json_with_indifferent_access'
2
+
3
+module JSONSerializedField
4
+  extend ActiveSupport::Concern
5
+
6
+  module ClassMethods
7
+    def json_serialize(*fields)
8
+      fields.each do |field|
9
+        class_eval <<-CODE
10
+          serialize :#{field}, JSONWithIndifferentAccess
11
+
12
+          validate :#{field}_has_no_errors
13
+
14
+          def #{field}=(input)
15
+            @#{field}_assignment_error = false
16
+            case input
17
+              when String
18
+                if input.strip.length == 0
19
+                  self[:#{field}] = ActiveSupport::HashWithIndifferentAccess.new
20
+                else
21
+                  json = JSON.parse(input) rescue nil
22
+                  if json
23
+                    self[:#{field}] = ActiveSupport::HashWithIndifferentAccess.new(json)
24
+                  else
25
+                    @#{field}_assignment_error = "was assigned invalid JSON"
26
+                  end
27
+                end
28
+              when Hash
29
+                self[:#{field}] = ActiveSupport::HashWithIndifferentAccess.new(input)
30
+              else
31
+                @#{field}_assignment_error = "cannot be set to an instance of \#{input.class}"
32
+            end
33
+          end
34
+
35
+          def #{field}_has_no_errors
36
+            errors.add(:#{field}, @#{field}_assignment_error) if @#{field}_assignment_error
37
+          end
38
+        CODE
39
+      end
40
+    end
41
+  end
42
+end

+ 9 - 0
lib/json_with_indifferent_access.rb

@@ -0,0 +1,9 @@
1
+class JSONWithIndifferentAccess
2
+  def self.load(json)
3
+    ActiveSupport::HashWithIndifferentAccess.new(JSON.parse(json || '{}'))
4
+  end
5
+
6
+  def self.dump(hash)
7
+    JSON.dump(hash)
8
+  end
9
+end

+ 0 - 43
lib/serialize_and_symbolize.rb

@@ -1,43 +0,0 @@
1
-module SerializeAndSymbolize
2
-  extend ActiveSupport::Concern
3
-
4
-  module ClassMethods
5
-    def serialize_and_symbolize(*column_names)
6
-      column_names.flatten.uniq.compact.map(&:to_sym).each do |column_name|
7
-        setup_name = "setup_#{column_name}".to_sym
8
-        symbolize_name = "symbolize_#{column_name}".to_sym
9
-        validate_name = "validate_#{column_name}".to_sym
10
-
11
-        serialize column_name
12
-        after_initialize setup_name
13
-        before_validation symbolize_name
14
-        before_save symbolize_name
15
-        validate validate_name
16
-
17
-        class_eval <<-RUBY
18
-          def #{setup_name}
19
-            self[:#{column_name}] ||= {}
20
-          end
21
-
22
-          def #{validate_name}
23
-            # Implement me in your subclass.
24
-          end
25
-
26
-          def #{symbolize_name}
27
-            self.#{column_name} = self[:#{column_name}]
28
-          end
29
-
30
-          def #{column_name}=(data)
31
-            if data.is_a?(String)
32
-              self[:#{column_name}] = JSON.parse(data).recursively_symbolize_keys rescue {}
33
-            elsif data.is_a?(Hash)
34
-              self[:#{column_name}] = data.recursively_symbolize_keys
35
-            else
36
-              self[:#{column_name}] = data
37
-            end
38
-          end
39
-        RUBY
40
-      end
41
-    end
42
-  end
43
-end

+ 0 - 11
lib/utils.rb

@@ -21,17 +21,6 @@ module Utils
21 21
     end
22 22
   end
23 23
 
24
-  def self.recursively_symbolize_keys(object)
25
-    case object
26
-      when Hash
27
-        object.inject({}) {|memo, (k, v)| memo[String === k ? k.to_sym : k] = recursively_symbolize_keys(v); memo }
28
-      when Array
29
-        object.map { |item| recursively_symbolize_keys item }
30
-      else
31
-        object
32
-    end
33
-  end
34
-
35 24
   def self.interpolate_jsonpaths(value, data)
36 25
     value.gsub(/<[^>]+>/).each { |jsonpath|
37 26
       Utils.values_at(data, jsonpath[1..-2]).first.to_s

+ 2 - 2
spec/controllers/agents_controller_spec.rb

@@ -5,7 +5,7 @@ describe AgentsController do
5 5
     {
6 6
         :type => "Agents::WebsiteAgent",
7 7
         :name => "Something",
8
-        :options => agents(:bob_website_agent).options.to_json,
8
+        :options => agents(:bob_website_agent).options,
9 9
         :source_ids => [agents(:bob_weather_agent).id, ""]
10 10
     }.merge(options)
11 11
   end
@@ -23,7 +23,7 @@ describe AgentsController do
23 23
       sign_in users(:bob)
24 24
       post :handle_details_post, :id => agents(:bob_manual_event_agent).to_param, :payload => { :foo => "bar" }
25 25
       JSON.parse(response.body).should == { "success" => true }
26
-      agents(:bob_manual_event_agent).events.last.payload.should == { :foo => "bar" }
26
+      agents(:bob_manual_event_agent).events.last.payload.should == { 'foo' => "bar" }
27 27
     end
28 28
 
29 29
     it "can only be accessed by the Agent's owner" do

+ 3 - 1
spec/controllers/logs_controller_spec.rb

@@ -19,12 +19,14 @@ describe LogsController do
19 19
 
20 20
   describe "DELETE clear" do
21 21
     it "deletes all logs for a specific Agent" do
22
+      agents(:bob_weather_agent).last_error_log_at = 2.hours.ago
22 23
       sign_in users(:bob)
23 24
       lambda {
24 25
         delete :clear, :agent_id => agents(:bob_weather_agent).id
25 26
       }.should change { AgentLog.count }.by(-1 * agents(:bob_weather_agent).logs.count)
26 27
       assigns(:logs).length.should == 0
27
-      agents(:bob_weather_agent).logs.count.should == 0
28
+      agents(:bob_weather_agent).reload.logs.count.should == 0
29
+      agents(:bob_weather_agent).last_error_log_at.should be_nil
28 30
     end
29 31
 
30 32
     it "only deletes logs for an Agent owned by the current user" do

+ 3 - 3
spec/controllers/user_location_updates_controller_spec.rb

@@ -8,7 +8,7 @@ describe UserLocationUpdatesController do
8 8
 
9 9
   it "should create events without requiring login" do
10 10
     post :create, :user_id => users(:bob).to_param, :secret => "my_secret", :longitude => 123, :latitude => 45, :something => "else"
11
-    @agent.events.last.payload.should == { :longitude => "123", :latitude => "45", :something => "else" }
11
+    @agent.events.last.payload.should == { 'longitude' => "123", 'latitude' => "45", 'something' => "else" }
12 12
     @agent.events.last.lat.should == 45
13 13
     @agent.events.last.lng.should == 123
14 14
   end
@@ -18,7 +18,7 @@ describe UserLocationUpdatesController do
18 18
     @jane_agent.save!
19 19
 
20 20
     post :create, :user_id => users(:bob).to_param, :secret => "my_secret", :longitude => 123, :latitude => 45, :something => "else"
21
-    @agent.events.last.payload.should == { :longitude => "123", :latitude => "45", :something => "else" }
21
+    @agent.events.last.payload.should == { 'longitude' => "123", 'latitude' => "45", 'something' => "else" }
22 22
     @jane_agent.events.should be_empty
23 23
   end
24 24
 
@@ -33,7 +33,7 @@ describe UserLocationUpdatesController do
33 33
 
34 34
     lambda {
35 35
       post :create, :user_id => users(:bob).to_param, :secret => "my_secret2", :longitude => 123, :latitude => 45, :something => "else"
36
-      @agent2.events.last.payload.should == { :longitude => "123", :latitude => "45", :something => "else" }
36
+      @agent2.events.last.payload.should == { 'longitude' => "123", 'latitude' => "45", 'something' => "else" }
37 37
     }.should_not change { @agent.events.count }
38 38
   end
39 39
 end

+ 2 - 2
spec/controllers/webhooks_controller_spec.rb

@@ -32,12 +32,12 @@ describe WebhooksController do
32 32
 
33 33
   it "should call receive_webhook" do
34 34
     post :create, :user_id => users(:bob).to_param, :agent_id => @agent.id, :secret => "my_secret", :key => "value", :another_key => "5"
35
-    @agent.reload.memory[:webhook_values].should == { :key => "value", :another_key => "5" }
35
+    @agent.reload.memory[:webhook_values].should == { 'key' => "value", 'another_key' => "5" }
36 36
     response.body.should == "success"
37 37
     response.should be_success
38 38
 
39 39
     post :create, :user_id => users(:bob).to_param, :agent_id => @agent.id, :secret => "not_my_secret", :no => "go"
40
-    @agent.reload.memory[:webhook_values].should_not == { :no => "go" }
40
+    @agent.reload.memory[:webhook_values].should_not == { 'no' => "go" }
41 41
     response.body.should == "failure"
42 42
     response.should be_missing
43 43
   end

+ 7 - 7
spec/fixtures/agents.yml

@@ -11,7 +11,7 @@ jane_website_agent:
11 11
                      :title => {:css => "item title", :text => true},
12 12
                      :url => {:css => "item link", :text => true}
13 13
                  }
14
-               }.to_yaml.inspect %>
14
+               }.to_json.inspect %>
15 15
 
16 16
 bob_website_agent:
17 17
   type: Agents::WebsiteAgent
@@ -26,7 +26,7 @@ bob_website_agent:
26 26
                    :url => {:css => "#comic img", :attr => "src"},
27 27
                    :title => {:css => "#comic img", :attr => "title"}
28 28
                  }
29
-               }.to_yaml.inspect %>
29
+               }.to_json.inspect %>
30 30
 
31 31
 bob_weather_agent:
32 32
   type: Agents::WeatherAgent
@@ -39,7 +39,7 @@ bob_weather_agent:
39 39
                  :lat => 37.779329,
40 40
                  :lng => -122.41915,
41 41
                  :api_key => 'test'
42
-               }.to_yaml.inspect %>
42
+               }.to_json.inspect %>
43 43
 
44 44
 jane_weather_agent:
45 45
   type: Agents::WeatherAgent
@@ -52,7 +52,7 @@ jane_weather_agent:
52 52
                  :lat => 37.779329,
53 53
                  :lng => -122.41915,
54 54
                  :api_key => 'test'
55
-               }.to_yaml.inspect %>
55
+               }.to_json.inspect %>
56 56
 
57 57
 jane_rain_notifier_agent:
58 58
   type: Agents::TriggerAgent
@@ -66,7 +66,7 @@ jane_rain_notifier_agent:
66 66
                    :path => "conditions"
67 67
                  }],
68 68
                  :message => "Just so you know, it looks like '<conditions>' tomorrow in <location>"
69
-               }.to_yaml.inspect %>
69
+               }.to_json.inspect %>
70 70
 
71 71
 bob_rain_notifier_agent:
72 72
   type: Agents::TriggerAgent
@@ -80,7 +80,7 @@ bob_rain_notifier_agent:
80 80
                    :path => "conditions"
81 81
                   }],
82 82
                  :message => "Just so you know, it looks like '<conditions>' tomorrow in <location>"
83
-               }.to_yaml.inspect %>
83
+               }.to_json.inspect %>
84 84
 
85 85
 bob_twitter_user_agent:
86 86
   type: Agents::TwitterUserAgent
@@ -93,7 +93,7 @@ bob_twitter_user_agent:
93 93
       :consumer_secret => "---",
94 94
       :oauth_token => "---",
95 95
       :oauth_token_secret => "---"
96
-    }.to_yaml.inspect %>
96
+    }.to_json.inspect %>
97 97
 
98 98
 bob_manual_event_agent:
99 99
   type: Agents::ManualEventAgent

+ 2 - 2
spec/fixtures/events.yml

@@ -1,9 +1,9 @@
1 1
 bob_website_agent_event:
2 2
   user: bob
3 3
   agent: bob_website_agent
4
-  payload: <%= [{ :title => "foo", :url => "http://foo.com" }].to_yaml.inspect %>
4
+  payload: <%= [{ :title => "foo", :url => "http://foo.com" }].to_json.inspect %>
5 5
 
6 6
 jane_website_agent_event:
7 7
   user: jane
8 8
   agent: jane_website_agent
9
-  payload: <%= [{ :title => "foo", :url => "http://foo.com" }].to_yaml.inspect %>
9
+  payload: <%= [{ :title => "foo", :url => "http://foo.com" }].to_json.inspect %>

+ 8 - 0
spec/models/agent_log_spec.rb

@@ -67,6 +67,14 @@ describe AgentLog do
67 67
       agents(:jane_website_agent).logs.order("agent_logs.id desc").first.message.should == "message 6"
68 68
       agents(:jane_website_agent).logs.order("agent_logs.id desc").last.message.should == "message 3"
69 69
     end
70
+
71
+    it "updates Agents' last_error_log_at when an error is logged" do
72
+      AgentLog.log_for_agent(agents(:jane_website_agent), "some message", :level => 3, :outbound_event => events(:jane_website_agent_event))
73
+      agents(:jane_website_agent).reload.last_error_log_at.should be_nil
74
+
75
+      AgentLog.log_for_agent(agents(:jane_website_agent), "some message", :level => 4, :outbound_event => events(:jane_website_agent_event))
76
+      agents(:jane_website_agent).reload.last_error_log_at.to_i.should be_within(2).of(Time.now.to_i)
77
+    end
70 78
   end
71 79
 
72 80
   describe "#log_length" do

+ 70 - 2
spec/models/agent_spec.rb

@@ -261,9 +261,51 @@ describe Agent do
261 261
       it "makes memory symbol-indifferent before validating" do
262 262
         agent = Agents::SomethingSource.new(:name => "something")
263 263
         agent.user = users(:bob)
264
-        agent.memory["bad"] = :hello
264
+        agent.memory["bad"] = 2
265 265
         agent.save
266
-        agent.memory[:bad].should == :hello
266
+        agent.memory[:bad].should == 2
267
+      end
268
+
269
+      it "should work when assigned a hash or JSON string" do
270
+        agent = Agents::SomethingSource.new(:name => "something")
271
+        agent.memory = {}
272
+        agent.memory.should == {}
273
+        agent.memory["foo"].should be_nil
274
+
275
+        agent.memory = ""
276
+        agent.memory["foo"].should be_nil
277
+        agent.memory.should == {}
278
+
279
+        agent.memory = '{"hi": "there"}'
280
+        agent.memory.should == { "hi" => "there" }
281
+
282
+        agent.memory = '{invalid}'
283
+        agent.memory.should == { "hi" => "there" }
284
+        agent.should have(1).errors_on(:memory)
285
+
286
+        agent.memory = "{}"
287
+        agent.memory["foo"].should be_nil
288
+        agent.memory.should == {}
289
+        agent.should have(0).errors_on(:memory)
290
+
291
+        agent.options = "{}"
292
+        agent.options["foo"].should be_nil
293
+        agent.options.should == {}
294
+        agent.should have(0).errors_on(:options)
295
+
296
+        agent.options = '{"hi": 2}'
297
+        agent.options["hi"].should == 2
298
+        agent.should have(0).errors_on(:options)
299
+
300
+        agent.options = '{"hi": wut}'
301
+        agent.options["hi"].should == 2
302
+        agent.should have(1).errors_on(:options)
303
+        agent.errors_on(:options).should include("was assigned invalid JSON")
304
+
305
+        agent.options = 5
306
+        agent.options["hi"].should == 2
307
+        agent.should have(1).errors_on(:options)
308
+        agent.errors_on(:options).should include("cannot be set to an instance of Fixnum")
267 309
       end
268 310
 
269 311
       it "should not allow agents owned by other people" do
@@ -357,6 +399,32 @@ describe Agent do
357 399
 
358 400
   end
359 401
 
402
+  describe "recent_error_logs?" do
403
+    it "returns true if last_error_log_at is near last_event_at" do
404
+      agent = Agent.new
405
+
406
+      agent.last_error_log_at = 10.minutes.ago
407
+      agent.last_event_at = 10.minutes.ago
408
+      agent.recent_error_logs?.should be_true
409
+
410
+      agent.last_error_log_at = 11.minutes.ago
411
+      agent.last_event_at = 10.minutes.ago
412
+      agent.recent_error_logs?.should be_true
413
+
414
+      agent.last_error_log_at = 5.minutes.ago
415
+      agent.last_event_at = 10.minutes.ago
416
+      agent.recent_error_logs?.should be_true
417
+
418
+      agent.last_error_log_at = 15.minutes.ago
419
+      agent.last_event_at = 10.minutes.ago
420
+      agent.recent_error_logs?.should be_false
421
+
422
+      agent.last_error_log_at = 2.days.ago
423
+      agent.last_event_at = 10.minutes.ago
424
+      agent.recent_error_logs?.should be_false
425
+    end
426
+  end
427
+
360 428
   describe "scopes" do
361 429
     describe "of_type" do
362 430
       it "should accept classes" do

+ 5 - 5
spec/models/agents/digest_email_agent_spec.rb

@@ -19,16 +19,16 @@ describe Agents::DigestEmailAgent do
19 19
     it "queues any payloads it receives" do
20 20
       event1 = Event.new
21 21
       event1.agent = agents(:bob_rain_notifier_agent)
22
-      event1.payload = "Something you should know about"
22
+      event1.payload = { :data => "Something you should know about" }
23 23
       event1.save!
24 24
 
25 25
       event2 = Event.new
26 26
       event2.agent = agents(:bob_weather_agent)
27
-      event2.payload = "Something else you should know about"
27
+      event2.payload = { :data => "Something else you should know about" }
28 28
       event2.save!
29 29
 
30 30
       Agents::DigestEmailAgent.async_receive(@checker.id, [event1.id, event2.id])
31
-      @checker.reload.memory[:queue].should == ["Something you should know about", "Something else you should know about"]
31
+      @checker.reload.memory[:queue].should == [{ 'data' => "Something you should know about" }, { 'data' => "Something else you should know about" }]
32 32
     end
33 33
   end
34 34
 
@@ -37,7 +37,7 @@ describe Agents::DigestEmailAgent do
37 37
       Agents::DigestEmailAgent.async_check(@checker.id)
38 38
       ActionMailer::Base.deliveries.should == []
39 39
 
40
-      @checker.memory[:queue] = ["Something you should know about",
40
+      @checker.memory[:queue] = [{ :data => "Something you should know about" },
41 41
                                  { :title => "Foo", :url => "http://google.com", :bar => 2 },
42 42
                                  { "message" => "hi", :woah => "there" },
43 43
                                  { "test" => 2 }]
@@ -47,7 +47,7 @@ describe Agents::DigestEmailAgent do
47 47
       Agents::DigestEmailAgent.async_check(@checker.id)
48 48
       ActionMailer::Base.deliveries.last.to.should == ["bob@example.com"]
49 49
       ActionMailer::Base.deliveries.last.subject.should == "something interesting"
50
-      get_message_part(ActionMailer::Base.deliveries.last, /plain/).strip.should == "Something you should know about\n\nFoo\n  bar: 2\n  url: http://google.com\n\nhi\n  woah: there\n\nEvent\n  test: 2"
50
+      get_message_part(ActionMailer::Base.deliveries.last, /plain/).strip.should == "Event\n  data: Something you should know about\n\nFoo\n  bar: 2\n  url: http://google.com\n\nhi\n  woah: there\n\nEvent\n  test: 2"
51 51
       @checker.reload.memory[:queue].should be_empty
52 52
     end
53 53
 

+ 4 - 4
spec/models/agents/email_agent_spec.rb

@@ -21,12 +21,12 @@ describe Agents::EmailAgent do
21 21
 
22 22
       event1 = Event.new
23 23
       event1.agent = agents(:bob_rain_notifier_agent)
24
-      event1.payload = "Something you should know about"
24
+      event1.payload = { :data => "Something you should know about" }
25 25
       event1.save!
26 26
 
27 27
       event2 = Event.new
28 28
       event2.agent = agents(:bob_weather_agent)
29
-      event2.payload = "Something else you should know about"
29
+      event2.payload = { :data => "Something else you should know about" }
30 30
       event2.save!
31 31
 
32 32
       Agents::EmailAgent.async_receive(@checker.id, [event1.id])
@@ -35,8 +35,8 @@ describe Agents::EmailAgent do
35 35
       ActionMailer::Base.deliveries.count.should == 2
36 36
       ActionMailer::Base.deliveries.last.to.should == ["bob@example.com"]
37 37
       ActionMailer::Base.deliveries.last.subject.should == "something interesting"
38
-      get_message_part(ActionMailer::Base.deliveries.last, /plain/).strip.should == "Something else you should know about"
39
-      get_message_part(ActionMailer::Base.deliveries.first, /plain/).strip.should == "Something you should know about"
38
+      get_message_part(ActionMailer::Base.deliveries.last, /plain/).strip.should == "Event\n  data: Something else you should know about"
39
+      get_message_part(ActionMailer::Base.deliveries.first, /plain/).strip.should == "Event\n  data: Something you should know about"
40 40
     end
41 41
 
42 42
     it "can receive complex events and send them on" do

+ 270 - 117
spec/models/agents/human_task_agent_spec.rb

@@ -9,8 +9,8 @@ describe Agents::HumanTaskAgent do
9 9
 
10 10
     @event = Event.new
11 11
     @event.agent = agents(:bob_rain_notifier_agent)
12
-    @event.payload = { :foo => { "bar" => { :baz => "a2b" } },
13
-                       :name => "Joe" }
12
+    @event.payload = { 'foo' => { "bar" => { 'baz' => "a2b" } },
13
+                       'name' => "Joe" }
14 14
     @event.id = 345
15 15
 
16 16
     @checker.should be_valid
@@ -18,115 +18,151 @@ describe Agents::HumanTaskAgent do
18 18
 
19 19
   describe "validations" do
20 20
     it "validates that trigger_on is 'schedule' or 'event'" do
21
-      @checker.options[:trigger_on] = "foo"
21
+      @checker.options['trigger_on'] = "foo"
22 22
       @checker.should_not be_valid
23 23
     end
24 24
 
25 25
     it "requires expected_receive_period_in_days when trigger_on is set to 'event'" do
26
-      @checker.options[:trigger_on] = "event"
27
-      @checker.options[:expected_receive_period_in_days] = nil
26
+      @checker.options['trigger_on'] = "event"
27
+      @checker.options['expected_receive_period_in_days'] = nil
28 28
       @checker.should_not be_valid
29
-      @checker.options[:expected_receive_period_in_days] = 2
29
+      @checker.options['expected_receive_period_in_days'] = 2
30 30
       @checker.should be_valid
31 31
     end
32 32
 
33 33
     it "requires a positive submission_period when trigger_on is set to 'schedule'" do
34
-      @checker.options[:trigger_on] = "schedule"
35
-      @checker.options[:submission_period] = nil
34
+      @checker.options['trigger_on'] = "schedule"
35
+      @checker.options['submission_period'] = nil
36 36
       @checker.should_not be_valid
37
-      @checker.options[:submission_period] = 2
37
+      @checker.options['submission_period'] = 2
38 38
       @checker.should be_valid
39 39
     end
40 40
 
41 41
     it "requires a hit.title" do
42
-      @checker.options[:hit][:title] = ""
42
+      @checker.options['hit']['title'] = ""
43 43
       @checker.should_not be_valid
44 44
     end
45 45
 
46 46
     it "requires a hit.description" do
47
-      @checker.options[:hit][:description] = ""
47
+      @checker.options['hit']['description'] = ""
48 48
       @checker.should_not be_valid
49 49
     end
50 50
 
51 51
     it "requires hit.assignments" do
52
-      @checker.options[:hit][:assignments] = ""
52
+      @checker.options['hit']['assignments'] = ""
53 53
       @checker.should_not be_valid
54
-      @checker.options[:hit][:assignments] = 0
54
+      @checker.options['hit']['assignments'] = 0
55 55
       @checker.should_not be_valid
56
-      @checker.options[:hit][:assignments] = "moose"
56
+      @checker.options['hit']['assignments'] = "moose"
57 57
       @checker.should_not be_valid
58
-      @checker.options[:hit][:assignments] = "2"
58
+      @checker.options['hit']['assignments'] = "2"
59 59
       @checker.should be_valid
60 60
     end
61 61
 
62 62
     it "requires hit.questions" do
63
-      old_questions = @checker.options[:hit][:questions]
64
-      @checker.options[:hit][:questions] = nil
63
+      old_questions = @checker.options['hit']['questions']
64
+      @checker.options['hit']['questions'] = nil
65 65
       @checker.should_not be_valid
66
-      @checker.options[:hit][:questions] = []
66
+      @checker.options['hit']['questions'] = []
67 67
       @checker.should_not be_valid
68
-      @checker.options[:hit][:questions] = [old_questions[0]]
68
+      @checker.options['hit']['questions'] = [old_questions[0]]
69 69
       @checker.should be_valid
70 70
     end
71 71
 
72 72
     it "requires that all questions have key, name, required, type, and question" do
73
-      old_questions = @checker.options[:hit][:questions]
74
-      @checker.options[:hit][:questions].first[:key] = ""
73
+      old_questions = @checker.options['hit']['questions']
74
+      @checker.options['hit']['questions'].first['key'] = ""
75 75
       @checker.should_not be_valid
76 76
 
77
-      @checker.options[:hit][:questions] = old_questions
78
-      @checker.options[:hit][:questions].first[:name] = ""
77
+      @checker.options['hit']['questions'] = old_questions
78
+      @checker.options['hit']['questions'].first['name'] = ""
79 79
       @checker.should_not be_valid
80 80
 
81
-      @checker.options[:hit][:questions] = old_questions
82
-      @checker.options[:hit][:questions].first[:required] = nil
81
+      @checker.options['hit']['questions'] = old_questions
82
+      @checker.options['hit']['questions'].first['required'] = nil
83 83
       @checker.should_not be_valid
84 84
 
85
-      @checker.options[:hit][:questions] = old_questions
86
-      @checker.options[:hit][:questions].first[:type] = ""
85
+      @checker.options['hit']['questions'] = old_questions
86
+      @checker.options['hit']['questions'].first['type'] = ""
87 87
       @checker.should_not be_valid
88 88
 
89
-      @checker.options[:hit][:questions] = old_questions
90
-      @checker.options[:hit][:questions].first[:question] = ""
89
+      @checker.options['hit']['questions'] = old_questions
90
+      @checker.options['hit']['questions'].first['question'] = ""
91 91
       @checker.should_not be_valid
92 92
     end
93 93
 
94 94
     it "requires that all questions of type 'selection' have a selections array with keys and text" do
95
-      @checker.options[:hit][:questions][0][:selections] = []
95
+      @checker.options['hit']['questions'][0]['selections'] = []
96 96
       @checker.should_not be_valid
97
-      @checker.options[:hit][:questions][0][:selections] = [{}]
97
+      @checker.options['hit']['questions'][0]['selections'] = [{}]
98 98
       @checker.should_not be_valid
99
-      @checker.options[:hit][:questions][0][:selections] = [{ :key => "", :text => "" }]
99
+      @checker.options['hit']['questions'][0]['selections'] = [{ 'key' => "", 'text' => "" }]
100 100
       @checker.should_not be_valid
101
-      @checker.options[:hit][:questions][0][:selections] = [{ :key => "", :text => "hi" }]
101
+      @checker.options['hit']['questions'][0]['selections'] = [{ 'key' => "", 'text' => "hi" }]
102 102
       @checker.should_not be_valid
103
-      @checker.options[:hit][:questions][0][:selections] = [{ :key => "hi", :text => "" }]
103
+      @checker.options['hit']['questions'][0]['selections'] = [{ 'key' => "hi", 'text' => "" }]
104 104
       @checker.should_not be_valid
105
-      @checker.options[:hit][:questions][0][:selections] = [{ :key => "hi", :text => "hi" }]
105
+      @checker.options['hit']['questions'][0]['selections'] = [{ 'key' => "hi", 'text' => "hi" }]
106 106
       @checker.should be_valid
107
-      @checker.options[:hit][:questions][0][:selections] = [{ :key => "hi", :text => "hi" }, {}]
107
+      @checker.options['hit']['questions'][0]['selections'] = [{ 'key' => "hi", 'text' => "hi" }, {}]
108 108
       @checker.should_not be_valid
109 109
     end
110 110
 
111
-    it "requires that all questions be of type 'selection' when `take_majority` is `true`" do
112
-      @checker.options[:take_majority] = "true"
111
+    it "requires that 'poll_options' be present and populated when 'combination_mode' is set to 'poll'" do
112
+      @checker.options['combination_mode'] = "poll"
113 113
       @checker.should_not be_valid
114
-      @checker.options[:hit][:questions][1][:type] = "selection"
115
-      @checker.options[:hit][:questions][1][:selections] = @checker.options[:hit][:questions][0][:selections]
114
+      @checker.options['poll_options'] = {}
115
+      @checker.should_not be_valid
116
+      @checker.options['poll_options'] = { 'title' => "Take a poll about jokes",
117
+                                           'instructions' => "Rank these by how funny they are",
118
+                                           'assignments' => 3,
119
+                                           'row_template' => "<$.joke>" }
120
+      @checker.should be_valid
121
+      @checker.options['poll_options'] = { 'instructions' => "Rank these by how funny they are",
122
+                                           'assignments' => 3,
123
+                                           'row_template' => "<$.joke>" }
124
+      @checker.should_not be_valid
125
+      @checker.options['poll_options'] = { 'title' => "Take a poll about jokes",
126
+                                           'assignments' => 3,
127
+                                           'row_template' => "<$.joke>" }
128
+      @checker.should_not be_valid
129
+      @checker.options['poll_options'] = { 'title' => "Take a poll about jokes",
130
+                                           'instructions' => "Rank these by how funny they are",
131
+                                           'row_template' => "<$.joke>" }
132
+      @checker.should_not be_valid
133
+      @checker.options['poll_options'] = { 'title' => "Take a poll about jokes",
134
+                                           'instructions' => "Rank these by how funny they are",
135
+                                           'assignments' => 3}
136
+      @checker.should_not be_valid
137
+    end
138
+
139
+    it "requires that all questions be of type 'selection' when 'combination_mode' is 'take_majority'" do
140
+      @checker.options['combination_mode'] = "take_majority"
141
+      @checker.should_not be_valid
142
+      @checker.options['hit']['questions'][1]['type'] = "selection"
143
+      @checker.options['hit']['questions'][1]['selections'] = @checker.options['hit']['questions'][0]['selections']
144
+      @checker.should be_valid
145
+    end
146
+
147
+    it "accepts 'take_majority': 'true' for legacy support" do
148
+      @checker.options['take_majority'] = "true"
149
+      @checker.should_not be_valid
150
+      @checker.options['hit']['questions'][1]['type'] = "selection"
151
+      @checker.options['hit']['questions'][1]['selections'] = @checker.options['hit']['questions'][0]['selections']
116 152
       @checker.should be_valid
117 153
     end
118 154
   end
119 155
 
120 156
   describe "when 'trigger_on' is set to 'schedule'" do
121 157
     before do
122
-      @checker.options[:trigger_on] = "schedule"
123
-      @checker.options[:submission_period] = "2"
124
-      @checker.options.delete(:expected_receive_period_in_days)
158
+      @checker.options['trigger_on'] = "schedule"
159
+      @checker.options['submission_period'] = "2"
160
+      @checker.options.delete('expected_receive_period_in_days')
125 161
     end
126 162
 
127 163
     it "should check for reviewable HITs frequently" do
128 164
       mock(@checker).review_hits.twice
129
-      mock(@checker).create_hit.once
165
+      mock(@checker).create_basic_hit.once
130 166
       @checker.check
131 167
       @checker.check
132 168
     end
@@ -135,7 +171,7 @@ describe Agents::HumanTaskAgent do
135 171
       now = Time.now
136 172
       stub(Time).now { now }
137 173
       mock(@checker).review_hits.times(3)
138
-      mock(@checker).create_hit.twice
174
+      mock(@checker).create_basic_hit.twice
139 175
       @checker.check
140 176
       now += 1 * 60 * 60
141 177
       @checker.check
@@ -144,18 +180,18 @@ describe Agents::HumanTaskAgent do
144 180
     end
145 181
 
146 182
     it "should ignore events" do
147
-      mock(@checker).create_hit(anything).times(0)
183
+      mock(@checker).create_basic_hit(anything).times(0)
148 184
       @checker.receive([events(:bob_website_agent_event)])
149 185
     end
150 186
   end
151 187
 
152 188
   describe "when 'trigger_on' is set to 'event'" do
153 189
     it "should not create HITs during check but should check for reviewable HITs" do
154
-      @checker.options[:submission_period] = "2"
190
+      @checker.options['submission_period'] = "2"
155 191
       now = Time.now
156 192
       stub(Time).now { now }
157 193
       mock(@checker).review_hits.times(3)
158
-      mock(@checker).create_hit.times(0)
194
+      mock(@checker).create_basic_hit.times(0)
159 195
       @checker.check
160 196
       now += 1 * 60 * 60
161 197
       @checker.check
@@ -164,16 +200,16 @@ describe Agents::HumanTaskAgent do
164 200
     end
165 201
 
166 202
     it "should create HITs based on events" do
167
-      mock(@checker).create_hit(events(:bob_website_agent_event)).times(1)
203
+      mock(@checker).create_basic_hit(events(:bob_website_agent_event)).times(1)
168 204
       @checker.receive([events(:bob_website_agent_event)])
169 205
     end
170 206
   end
171 207
 
172 208
   describe "creating hits" do
173 209
     it "can create HITs based on events, interpolating their values" do
174
-      @checker.options[:hit][:title] = "Hi <.name>"
175
-      @checker.options[:hit][:description] = "Make something for <.name>"
176
-      @checker.options[:hit][:questions][0][:name] = "<.name> Question 1"
210
+      @checker.options['hit']['title'] = "Hi <.name>"
211
+      @checker.options['hit']['description'] = "Make something for <.name>"
212
+      @checker.options['hit']['questions'][0]['name'] = "<.name> Question 1"
177 213
 
178 214
       question_form = nil
179 215
       hitInterface = OpenStruct.new
@@ -181,10 +217,10 @@ describe Agents::HumanTaskAgent do
181 217
       mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm) { |agent_question_form_instance| question_form = agent_question_form_instance }
182 218
       mock(RTurk::Hit).create(:title => "Hi Joe").yields(hitInterface) { hitInterface }
183 219
 
184
-      @checker.send :create_hit, @event
220
+      @checker.send :create_basic_hit, @event
185 221
 
186
-      hitInterface.max_assignments.should == @checker.options[:hit][:assignments]
187
-      hitInterface.reward.should == @checker.options[:hit][:reward]
222
+      hitInterface.max_assignments.should == @checker.options['hit']['assignments']
223
+      hitInterface.reward.should == @checker.options['hit']['reward']
188 224
       hitInterface.description.should == "Make something for Joe"
189 225
 
190 226
       xml = question_form.to_xml
@@ -192,18 +228,18 @@ describe Agents::HumanTaskAgent do
192 228
       xml.should include("<Text>Make something for Joe</Text>")
193 229
       xml.should include("<DisplayName>Joe Question 1</DisplayName>")
194 230
 
195
-      @checker.memory[:hits][123].should == @event.id
231
+      @checker.memory['hits'][123]['event_id'].should == @event.id
196 232
     end
197 233
 
198 234
     it "works without an event too" do
199
-      @checker.options[:hit][:title] = "Hi <.name>"
235
+      @checker.options['hit']['title'] = "Hi <.name>"
200 236
       hitInterface = OpenStruct.new
201 237
       hitInterface.id = 123
202 238
       mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm)
203 239
       mock(RTurk::Hit).create(:title => "Hi").yields(hitInterface) { hitInterface }
204
-      @checker.send :create_hit
205
-      hitInterface.max_assignments.should == @checker.options[:hit][:assignments]
206
-      hitInterface.reward.should == @checker.options[:hit][:reward]
240
+      @checker.send :create_basic_hit
241
+      hitInterface.max_assignments.should == @checker.options['hit']['assignments']
242
+      hitInterface.reward.should == @checker.options['hit']['reward']
207 243
     end
208 244
   end
209 245
 
@@ -253,14 +289,14 @@ describe Agents::HumanTaskAgent do
253 289
     it "should work on multiple HITs" do
254 290
       event2 = Event.new
255 291
       event2.agent = agents(:bob_rain_notifier_agent)
256
-      event2.payload = { :foo2 => { "bar2" => { :baz2 => "a2b2" } },
257
-                          :name2 => "Joe2" }
292
+      event2.payload = { 'foo2' => { "bar2" => { 'baz2' => "a2b2" } },
293
+                         'name2' => "Joe2" }
258 294
       event2.id = 3452
259 295
 
260 296
       # It knows about two HITs from two different events.
261
-      @checker.memory[:hits] = {}
262
-      @checker.memory[:hits][:"JH3132836336DHG"] = @event.id
263
-      @checker.memory[:hits][:"JH39AA63836DHG"] = event2.id
297
+      @checker.memory['hits'] = {}
298
+      @checker.memory['hits']["JH3132836336DHG"] = { 'event_id' => @event.id }
299
+      @checker.memory['hits']["JH39AA63836DHG"] = { 'event_id' => event2.id }
264 300
 
265 301
       hit_ids = %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345]
266 302
       mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { hit_ids } } # It sees 3 HITs.
@@ -273,7 +309,7 @@ describe Agents::HumanTaskAgent do
273 309
     end
274 310
 
275 311
     it "shouldn't do anything if an assignment isn't ready" do
276
-      @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
312
+      @checker.memory['hits'] = { "JH3132836336DHG" => { 'event_id' => @event.id } }
277 313
       mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
278 314
       assignments = [
279 315
         FakeAssignment.new(:status => "Accepted", :answers => {}),
@@ -288,11 +324,11 @@ describe Agents::HumanTaskAgent do
288 324
       @checker.send :review_hits
289 325
 
290 326
       assignments.all? {|a| a.approved == true }.should be_false
291
-      @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id }
327
+      @checker.memory['hits'].should == { "JH3132836336DHG" => { 'event_id' => @event.id } }
292 328
     end
293 329
 
294 330
     it "shouldn't do anything if an assignment is missing" do
295
-      @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
331
+      @checker.memory['hits'] = { "JH3132836336DHG" => { 'event_id' => @event.id } }
296 332
       mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
297 333
       assignments = [
298 334
         FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"})
@@ -306,11 +342,11 @@ describe Agents::HumanTaskAgent do
306 342
       @checker.send :review_hits
307 343
 
308 344
       assignments.all? {|a| a.approved == true }.should be_false
309
-      @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id }
345
+      @checker.memory['hits'].should == { "JH3132836336DHG" => { 'event_id' => @event.id } }
310 346
     end
311 347
 
312 348
     it "should create events when all assignments are ready" do
313
-      @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
349
+      @checker.memory['hits'] = { "JH3132836336DHG" => { 'event_id' => @event.id } }
314 350
       mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
315 351
       assignments = [
316 352
         FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"neutral", "feedback"=>""}),
@@ -327,32 +363,32 @@ describe Agents::HumanTaskAgent do
327 363
       assignments.all? {|a| a.approved == true }.should be_true
328 364
       hit.should be_disposed
329 365
 
330
-      @checker.events.last.payload[:answers].should == [
331
-        {:sentiment => "neutral", :feedback => ""},
332
-        {:sentiment => "happy", :feedback => "Take 2"}
366
+      @checker.events.last.payload['answers'].should == [
367
+        {'sentiment' => "neutral", 'feedback' => ""},
368
+        {'sentiment' => "happy", 'feedback' => "Take 2"}
333 369
       ]
334 370
 
335
-      @checker.memory[:hits].should == {}
371
+      @checker.memory['hits'].should == {}
336 372
     end
337 373
 
338 374
     describe "taking majority votes" do
339 375
       before do
340
-        @checker.options[:take_majority] = "true"
341
-        @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
376
+        @checker.options['combination_mode'] = "take_majority"
377
+        @checker.memory['hits'] = { "JH3132836336DHG" => { 'event_id' => @event.id } }
342 378
         mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
343 379
       end
344 380
 
345 381
       it "should take the majority votes of all questions" do
346
-        @checker.options[:hit][:questions][1] = {
347
-          :type => "selection",
348
-          :key => "age_range",
349
-          :name => "Age Range",
350
-          :required => "true",
351
-          :question => "Please select your age range:",
352
-          :selections =>
382
+        @checker.options['hit']['questions'][1] = {
383
+          'type' => "selection",
384
+          'key' => "age_range",
385
+          'name' => "Age Range",
386
+          'required' => "true",
387
+          'question' => "Please select your age range:",
388
+          'selections' =>
353 389
             [
354
-              { :key => "<50", :text => "50 years old or younger" },
355
-              { :key => ">50", :text => "Over 50 years old" }
390
+              { 'key' => "<50", 'text' => "50 years old or younger" },
391
+              { 'key' => ">50", 'text' => "Over 50 years old" }
356 392
             ]
357 393
         }
358 394
 
@@ -371,35 +407,39 @@ describe Agents::HumanTaskAgent do
371 407
 
372 408
         assignments.all? {|a| a.approved == true }.should be_true
373 409
 
374
-        @checker.events.last.payload[:answers].should == [
375
-          { :sentiment => "sad", :age_range => "<50" },
376
-          { :sentiment => "neutral", :age_range => ">50" },
377
-          { :sentiment => "happy", :age_range => ">50" },
378
-          { :sentiment => "happy", :age_range => ">50" }
410
+        @checker.events.last.payload['answers'].should == [
411
+          { 'sentiment' => "sad", 'age_range' => "<50" },
412
+          { 'sentiment' => "neutral", 'age_range' => ">50" },
413
+          { 'sentiment' => "happy", 'age_range' => ">50" },
414
+          { 'sentiment' => "happy", 'age_range' => ">50" }
379 415
         ]
380 416
 
381
-        @checker.events.last.payload[:counts].should == { :sentiment => { :happy => 2, :sad => 1, :neutral => 1 }, :age_range => { :">50" => 3, :"<50" => 1 } }
382
-        @checker.events.last.payload[:majority_answer].should == { :sentiment => "happy", :age_range => ">50" }
383
-        @checker.events.last.payload.should_not have_key(:average_answer)
417
+        @checker.events.last.payload['counts'].should == { 'sentiment' => { 'happy' => 2, 'sad' => 1, 'neutral' => 1 }, 'age_range' => { ">50" => 3, "<50" => 1 } }
418
+        @checker.events.last.payload['majority_answer'].should == { 'sentiment' => "happy", 'age_range' => ">50" }
419
+        @checker.events.last.payload.should_not have_key('average_answer')
384 420
 
385
-        @checker.memory[:hits].should == {}
421
+        @checker.memory['hits'].should == {}
386 422
       end
387 423
 
388 424
       it "should also provide an average answer when all questions are numeric" do
389
-        @checker.options[:hit][:questions] = [
425
+        # it should accept 'take_majority': 'true' as well for legacy support.  Demonstrating that here.
426
+        @checker.options.delete :combination_mode
427
+        @checker.options['take_majority'] = "true"
428
+
429
+        @checker.options['hit']['questions'] = [
390 430
           {
391
-            :type => "selection",
392
-            :key => "rating",
393
-            :name => "Rating",
394
-            :required => "true",
395
-            :question => "Please select a rating:",
396
-            :selections =>
431
+            'type' => "selection",
432
+            'key' => "rating",
433
+            'name' => "Rating",
434
+            'required' => "true",
435
+            'question' => "Please select a rating:",
436
+            'selections' =>
397 437
               [
398
-                { :key => "1", :text => "One" },
399
-                { :key => "2", :text => "Two" },
400
-                { :key => "3", :text => "Three" },
401
-                { :key => "4", :text => "Four" },
402
-                { :key => "5.1", :text => "Five Point One" }
438
+                { 'key' => "1", 'text' => "One" },
439
+                { 'key' => "2", 'text' => "Two" },
440
+                { 'key' => "3", 'text' => "Three" },
441
+                { 'key' => "4", 'text' => "Four" },
442
+                { 'key' => "5.1", 'text' => "Five Point One" }
403 443
               ]
404 444
           }
405 445
         ]
@@ -420,20 +460,133 @@ describe Agents::HumanTaskAgent do
420 460
 
421 461
         assignments.all? {|a| a.approved == true }.should be_true
422 462
 
423
-        @checker.events.last.payload[:answers].should == [
424
-          { :rating => "1" },
425
-          { :rating => "3" },
426
-          { :rating => "5.1" },
427
-          { :rating => "2" },
428
-          { :rating => "2" }
463
+        @checker.events.last.payload['answers'].should == [
464
+          { 'rating' => "1" },
465
+          { 'rating' => "3" },
466
+          { 'rating' => "5.1" },
467
+          { 'rating' => "2" },
468
+          { 'rating' => "2" }
429 469
         ]
430 470
 
431
-        @checker.events.last.payload[:counts].should == { :rating => { :"1" => 1, :"2" => 2, :"3" => 1, :"4" => 0, :"5.1" => 1 } }
432
-        @checker.events.last.payload[:majority_answer].should == { :rating => "2" }
433
-        @checker.events.last.payload[:average_answer].should == { :rating => (1 + 2 + 2 + 3 + 5.1) / 5.0 }
471
+        @checker.events.last.payload['counts'].should == { 'rating' => { "1" => 1, "2" => 2, "3" => 1, "4" => 0, "5.1" => 1 } }
472
+        @checker.events.last.payload['majority_answer'].should == { 'rating' => "2" }
473
+        @checker.events.last.payload['average_answer'].should == { 'rating' => (1 + 2 + 2 + 3 + 5.1) / 5.0 }
474
+
475
+        @checker.memory['hits'].should == {}
476
+      end
477
+    end
478
+
479
+    describe "creating and reviewing polls" do
480
+      before do
481
+        @checker.options['combination_mode'] = "poll"
482
+        @checker.options['poll_options'] = {
483
+          'title' => "Hi!",
484
+          'instructions' => "hello!",
485
+          'assignments' => 2,
486
+          'row_template' => "This is <.sentiment>"
487
+        }
488
+        @event.save!
489
+        mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
490
+      end
491
+
492
+      it "creates a poll using the row_template, message, and correct number of assignments" do
493
+        @checker.memory['hits'] = { "JH3132836336DHG" => { 'event_id' => @event.id } }
494
+
495
+        # Mock out the HIT's submitted assignments.
496
+        assignments = [
497
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"sad",     "feedback"=>"This is my feedback 1"}),
498
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"neutral", "feedback"=>"This is my feedback 2"}),
499
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy",   "feedback"=>"This is my feedback 3"}),
500
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy",   "feedback"=>"This is my feedback 4"})
501
+        ]
502
+        hit = FakeHit.new(:max_assignments => 4, :assignments => assignments)
503
+        mock(RTurk::Hit).new("JH3132836336DHG") { hit }
504
+
505
+        @checker.memory['hits']["JH3132836336DHG"].should be_present
506
+
507
+        # Setup mocks for HIT creation
508
+
509
+        question_form = nil
510
+        hitInterface = OpenStruct.new
511
+        hitInterface.id = "JH39AA63836DH12345"
512
+        mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm) { |agent_question_form_instance| question_form = agent_question_form_instance }
513
+        mock(RTurk::Hit).create(:title => "Hi!").yields(hitInterface) { hitInterface }
514
+
515
+        # And finally, the test.
516
+
517
+        lambda {
518
+          @checker.send :review_hits
519
+        }.should change { Event.count }.by(0) # it does not emit an event until all poll results are in
520
+
521
+        # it approves the existing assignments
522
+
523
+        assignments.all? {|a| a.approved == true }.should be_true
524
+        hit.should be_disposed
525
+
526
+        # it creates a new HIT for the poll
527
+
528
+        hitInterface.max_assignments.should == @checker.options['poll_options']['assignments']
529
+        hitInterface.description.should == @checker.options['poll_options']['instructions']
530
+
531
+        xml = question_form.to_xml
532
+        xml.should include("<Text>This is happy</Text>")
533
+        xml.should include("<Text>This is neutral</Text>")
534
+        xml.should include("<Text>This is sad</Text>")
535
+
536
+        @checker.save
537
+        @checker.reload
538
+        @checker.memory['hits']["JH3132836336DHG"].should_not be_present
539
+        @checker.memory['hits']["JH39AA63836DH12345"].should be_present
540
+        @checker.memory['hits']["JH39AA63836DH12345"]['event_id'].should == @event.id
541
+        @checker.memory['hits']["JH39AA63836DH12345"]['type'].should == "poll"
542
+        @checker.memory['hits']["JH39AA63836DH12345"]['original_hit'].should == "JH3132836336DHG"
543
+        @checker.memory['hits']["JH39AA63836DH12345"]['answers'].length.should == 4
544
+      end
545
+
546
+      it "emits an event when all poll results are in, containing the data from the best answer, plus all others" do
547
+        original_answers = [
548
+          { 'sentiment' => "sad",     'feedback' => "This is my feedback 1"},
549
+          { 'sentiment' => "neutral", 'feedback' => "This is my feedback 2"},
550
+          { 'sentiment' => "happy",   'feedback' => "This is my feedback 3"},
551
+          { 'sentiment' => "happy",   'feedback' => "This is my feedback 4"}
552
+        ]
553
+
554
+        @checker.memory['hits'] = {
555
+          'JH39AA63836DH12345' => {
556
+            'type' => 'poll',
557
+            'original_hit' => "JH3132836336DHG",
558
+            'answers' => original_answers,
559
+            'event_id' => 345
560
+          }
561
+        }
562
+
563
+        # Mock out the HIT's submitted assignments.
564
+        assignments = [
565
+          FakeAssignment.new(:status => "Submitted", :answers => {"1" => "2", "2" => "5", "3" => "3", "4" => "2"}),
566
+          FakeAssignment.new(:status => "Submitted", :answers => {"1" => "3", "2" => "4", "3" => "1", "4" => "4"})
567
+        ]
568
+        hit = FakeHit.new(:max_assignments => 2, :assignments => assignments)
569
+        mock(RTurk::Hit).new("JH39AA63836DH12345") { hit }
570
+
571
+        @checker.memory['hits']["JH39AA63836DH12345"].should be_present
572
+
573
+        lambda {
574
+          @checker.send :review_hits
575
+        }.should change { Event.count }.by(1)
576
+
577
+        # It emits an event
578
+
579
+        @checker.events.last.payload['answers'].should == original_answers
580
+        @checker.events.last.payload['poll'].should == [{"1" => "2", "2" => "5", "3" => "3", "4" => "2"}, {"1" => "3", "2" => "4", "3" => "1", "4" => "4"}]
581
+        @checker.events.last.payload['best_answer'].should == {'sentiment' => "neutral", 'feedback' => "This is my feedback 2"}
582
+
583
+        # it approves the existing assignments
584
+
585
+        assignments.all? {|a| a.approved == true }.should be_true
586
+        hit.should be_disposed
434 587
 
435
-        @checker.memory[:hits].should == {}
588
+        @checker.memory['hits'].should be_empty
436 589
       end
437 590
     end
438 591
   end
439
-end
592
+end

+ 29 - 29
spec/models/agents/peak_detector_agent_spec.rb

@@ -3,12 +3,12 @@ require 'spec_helper'
3 3
 describe Agents::PeakDetectorAgent do
4 4
   before do
5 5
     @valid_params = {
6
-        :name => "my peak detector agent",
7
-        :options => {
8
-            :expected_receive_period_in_days => "2",
9
-            :group_by_path => "filter",
10
-            :value_path => "count",
11
-            :message => "A peak was found"
6
+        'name' => "my peak detector agent",
7
+        'options' => {
8
+          'expected_receive_period_in_days' => "2",
9
+          'group_by_path' => "filter",
10
+          'value_path' => "count",
11
+          'message' => "A peak was found"
12 12
         }
13 13
     }
14 14
 
@@ -19,54 +19,54 @@ describe Agents::PeakDetectorAgent do
19 19
 
20 20
   describe "#receive" do
21 21
     it "tracks and groups by the group_by_path" do
22
-      events = build_events(:keys => [:count, :filter],
22
+      events = build_events(:keys => ['count', 'filter'],
23 23
                             :values => [[1, "something"], [2, "something"], [3, "else"]])
24 24
       @agent.receive events
25
-      @agent.memory[:data][:something].map(&:first).should == [1, 2]
26
-      @agent.memory[:data][:something].last.last.should be_within(10).of((100 - 1).hours.ago.to_i)
27
-      @agent.memory[:data][:else].first.first.should == 3
28
-      @agent.memory[:data][:else].first.last.should be_within(10).of((100 - 2).hours.ago.to_i)
25
+      @agent.memory['data']['something'].map(&:first).should == [1, 2]
26
+      @agent.memory['data']['something'].last.last.should be_within(10).of((100 - 1).hours.ago.to_i)
27
+      @agent.memory['data']['else'].first.first.should == 3
28
+      @agent.memory['data']['else'].first.last.should be_within(10).of((100 - 2).hours.ago.to_i)
29 29
     end
30 30
 
31 31
     it "works without a group_by_path as well" do
32
-      @agent.options[:group_by_path] = ""
33
-      events = build_events(:keys => [:count], :values => [[1], [2]])
32
+      @agent.options['group_by_path'] = ""
33
+      events = build_events(:keys => ['count'], :values => [[1], [2]])
34 34
       @agent.receive events
35
-      @agent.memory[:data][:no_group].map(&:first).should == [1, 2]
35
+      @agent.memory['data']['no_group'].map(&:first).should == [1, 2]
36 36
     end
37 37
 
38 38
     it "keeps a rolling window of data" do
39
-      @agent.options[:window_duration_in_days] = 5/24.0
40
-      @agent.receive build_events(:keys => [:count],
39
+      @agent.options['window_duration_in_days'] = 5/24.0
40
+      @agent.receive build_events(:keys => ['count'],
41 41
                                   :values => [1, 2, 3, 4, 5, 6, 7, 8].map {|i| [i]},
42
-                                  :pattern => { :filter => "something" })
43
-      @agent.memory[:data][:something].map(&:first).should == [4, 5, 6, 7, 8]
42
+                                  :pattern => { 'filter' => "something" })
43
+      @agent.memory['data']['something'].map(&:first).should == [4, 5, 6, 7, 8]
44 44
     end
45 45
 
46 46
     it "finds peaks" do
47
-      build_events(:keys => [:count],
47
+      build_events(:keys => ['count'],
48 48
                    :values => [5, 6,
49 49
                                4, 5,
50 50
                                4, 5,
51 51
                                15, 11, # peak
52 52
                                8, 50, # ignored because it's too close to the first peak
53 53
                                4, 5].map {|i| [i]},
54
-                   :pattern => { :filter => "something" }).each.with_index do |event, index|
54
+                   :pattern => { 'filter' => "something" }).each.with_index do |event, index|
55 55
         lambda {
56 56
           @agent.receive([event])
57 57
         }.should change { @agent.events.count }.by( index == 6 ? 1 : 0 )
58 58
       end
59 59
 
60
-      @agent.events.last.payload[:peak].should == 15.0
61
-      @agent.memory[:peaks][:something].length.should == 1
60
+      @agent.events.last.payload['peak'].should == 15.0
61
+      @agent.memory['peaks']['something'].length.should == 1
62 62
     end
63 63
 
64 64
     it "keeps a rolling window of peaks" do
65
-      @agent.options[:min_peak_spacing_in_days] = 1/24.0
66
-      @agent.receive build_events(:keys => [:count],
65
+      @agent.options['min_peak_spacing_in_days'] = 1/24.0
66
+      @agent.receive build_events(:keys => ['count'],
67 67
                                   :values => [1, 1, 1, 1, 1, 1, 10, 1, 1, 1, 1, 1, 1, 1, 10, 1].map {|i| [i]},
68
-                                  :pattern => { :filter => "something" })
69
-      @agent.memory[:peaks][:something].length.should == 2
68
+                                  :pattern => { 'filter' => "something" })
69
+      @agent.memory['peaks']['something'].length.should == 2
70 70
     end
71 71
   end
72 72
 
@@ -76,17 +76,17 @@ describe Agents::PeakDetectorAgent do
76 76
     end
77 77
 
78 78
     it "should validate presence of message" do
79
-      @agent.options[:message] = nil
79
+      @agent.options['message'] = nil
80 80
       @agent.should_not be_valid
81 81
     end
82 82
 
83 83
     it "should validate presence of expected_receive_period_in_days" do
84
-      @agent.options[:expected_receive_period_in_days] = ""
84
+      @agent.options['expected_receive_period_in_days'] = ""
85 85
       @agent.should_not be_valid
86 86
     end
87 87
 
88 88
     it "should validate presence of value_path" do
89
-      @agent.options[:value_path] = ""
89
+      @agent.options['value_path'] = ""
90 90
       @agent.should_not be_valid
91 91
     end
92 92
   end

+ 1 - 1
spec/models/agents/sentiment_agent_spec.rb

@@ -53,7 +53,7 @@ describe Agents::SentimentAgent do
53 53
         it "checks if content key is working fine" do
54 54
             @checker.receive([@event])
55 55
             Event.last.payload[:content].should == "value1"
56
-            Event.last.payload[:original_event].should == {:message => "value1"}
56
+            Event.last.payload[:original_event].should == { 'message' => "value1" }
57 57
         end
58 58
         it "should handle multiple events" do
59 59
             event1 = Event.new

+ 53 - 53
spec/models/agents/trigger_agent_spec.rb

@@ -3,16 +3,16 @@ require 'spec_helper'
3 3
 describe Agents::TriggerAgent do
4 4
   before do
5 5
     @valid_params = {
6
-        :name => "my trigger agent",
7
-        :options => {
8
-            :expected_receive_period_in_days => 2,
9
-            :rules => [{
10
-                           :type => "regex",
11
-                           'value' => "a\\db",
12
-                           :path => "foo.bar.baz",
13
-                       }],
14
-            :message => "I saw '<foo.bar.baz>' from <name>"
15
-        }
6
+      'name' => "my trigger agent",
7
+      'options' => {
8
+        'expected_receive_period_in_days' => 2,
9
+        'rules' => [{
10
+                      'type' => "regex",
11
+                      'value' => "a\\db",
12
+                      'path' => "foo.bar.baz",
13
+                    }],
14
+        'message' => "I saw '<foo.bar.baz>' from <name>"
15
+      }
16 16
     }
17 17
 
18 18
     @checker = Agents::TriggerAgent.new(@valid_params)
@@ -21,8 +21,8 @@ describe Agents::TriggerAgent do
21 21
 
22 22
     @event = Event.new
23 23
     @event.agent = agents(:bob_rain_notifier_agent)
24
-    @event.payload = { :foo => { "bar" => { :baz => "a2b" }},
25
-                       :name => "Joe" }
24
+    @event.payload = { 'foo' => { "bar" => { 'baz' => "a2b" }},
25
+                       'name' => "Joe" }
26 26
   end
27 27
 
28 28
   describe "validation" do
@@ -31,22 +31,22 @@ describe Agents::TriggerAgent do
31 31
     end
32 32
 
33 33
     it "should validate presence of options" do
34
-      @checker.options[:message] = nil
34
+      @checker.options['message'] = nil
35 35
       @checker.should_not be_valid
36 36
     end
37 37
 
38 38
     it "should validate the three fields in each rule" do
39
-      @checker.options[:rules] << { :path => "foo", :type => "fake", :value => "6" }
39
+      @checker.options['rules'] << { 'path' => "foo", 'type' => "fake", 'value' => "6" }
40 40
       @checker.should_not be_valid
41
-      @checker.options[:rules].last[:type] = "field>=value"
41
+      @checker.options['rules'].last['type'] = "field>=value"
42 42
       @checker.should be_valid
43
-      @checker.options[:rules].last.delete(:value)
43
+      @checker.options['rules'].last.delete('value')
44 44
       @checker.should_not be_valid
45 45
     end
46 46
   end
47 47
 
48 48
   describe "#working?" do
49
-    it "checks to see if the Agent has received any events in the last :expected_receive_period_in_days days" do
49
+    it "checks to see if the Agent has received any events in the last 'expected_receive_period_in_days' days" do
50 50
       @event.save!
51 51
 
52 52
       @checker.should_not be_working # no events have ever been received
@@ -60,30 +60,30 @@ describe Agents::TriggerAgent do
60 60
 
61 61
   describe "#receive" do
62 62
     it "handles regex" do
63
-      @event.payload[:foo]["bar"][:baz] = "a222b"
63
+      @event.payload['foo']['bar']['baz'] = "a222b"
64 64
       lambda {
65 65
         @checker.receive([@event])
66 66
       }.should_not change { Event.count }
67 67
 
68
-      @event.payload[:foo]["bar"][:baz] = "a2b"
68
+      @event.payload['foo']['bar']['baz'] = "a2b"
69 69
       lambda {
70 70
         @checker.receive([@event])
71 71
       }.should change { Event.count }.by(1)
72 72
     end
73 73
 
74 74
     it "handles negated regex" do
75
-      @event.payload[:foo]["bar"][:baz] = "a2b"
76
-      @checker.options[:rules][0] = {
77
-                                      :type => "!regex",
78
-                                      :value => "a\\db",
79
-                                      :path => "foo.bar.baz",
80
-                                    }
75
+      @event.payload['foo']['bar']['baz'] = "a2b"
76
+      @checker.options['rules'][0] = {
77
+        'type' => "!regex",
78
+        'value' => "a\\db",
79
+        'path' => "foo.bar.baz",
80
+      }
81 81
 
82 82
       lambda {
83 83
         @checker.receive([@event])
84 84
       }.should_not change { Event.count }
85 85
 
86
-      @event.payload[:foo]["bar"][:baz] = "a22b"
86
+      @event.payload['foo']['bar']['baz'] = "a22b"
87 87
       lambda {
88 88
         @checker.receive([@event])
89 89
       }.should change { Event.count }.by(1)
@@ -91,49 +91,49 @@ describe Agents::TriggerAgent do
91 91
 
92 92
     it "puts can extract values into the message based on paths" do
93 93
       @checker.receive([@event])
94
-      Event.last.payload[:message].should == "I saw 'a2b' from Joe"
94
+      Event.last.payload['message'].should == "I saw 'a2b' from Joe"
95 95
     end
96 96
 
97 97
     it "handles numerical comparisons" do
98
-      @event.payload[:foo]["bar"][:baz] = "5"
99
-      @checker.options[:rules].first[:value] = 6
100
-      @checker.options[:rules].first[:type] = "field<value"
98
+      @event.payload['foo']['bar']['baz'] = "5"
99
+      @checker.options['rules'].first['value'] = 6
100
+      @checker.options['rules'].first['type'] = "field<value"
101 101
 
102 102
       lambda {
103 103
         @checker.receive([@event])
104 104
       }.should change { Event.count }.by(1)
105 105
 
106
-      @checker.options[:rules].first[:value] = 3
106
+      @checker.options['rules'].first['value'] = 3
107 107
       lambda {
108 108
         @checker.receive([@event])
109 109
       }.should_not change { Event.count }
110 110
     end
111 111
 
112 112
     it "handles exact comparisons" do
113
-      @event.payload[:foo]["bar"][:baz] = "hello world"
114
-      @checker.options[:rules].first[:type] = "field==value"
113
+      @event.payload['foo']['bar']['baz'] = "hello world"
114
+      @checker.options['rules'].first['type'] = "field==value"
115 115
 
116
-      @checker.options[:rules].first[:value] = "hello there"
116
+      @checker.options['rules'].first['value'] = "hello there"
117 117
       lambda {
118 118
         @checker.receive([@event])
119 119
       }.should_not change { Event.count }
120 120
 
121
-      @checker.options[:rules].first[:value] = "hello world"
121
+      @checker.options['rules'].first['value'] = "hello world"
122 122
       lambda {
123 123
         @checker.receive([@event])
124 124
       }.should change { Event.count }.by(1)
125 125
     end
126 126
 
127 127
     it "handles negated comparisons" do
128
-      @event.payload[:foo]["bar"][:baz] = "hello world"
129
-      @checker.options[:rules].first[:type] = "field!=value"
130
-      @checker.options[:rules].first[:value] = "hello world"
128
+      @event.payload['foo']['bar']['baz'] = "hello world"
129
+      @checker.options['rules'].first['type'] = "field!=value"
130
+      @checker.options['rules'].first['value'] = "hello world"
131 131
 
132 132
       lambda {
133 133
         @checker.receive([@event])
134 134
       }.should_not change { Event.count }
135 135
 
136
-      @checker.options[:rules].first[:value] = "hello there"
136
+      @checker.options['rules'].first['value'] = "hello there"
137 137
 
138 138
       lambda {
139 139
         @checker.receive([@event])
@@ -141,20 +141,20 @@ describe Agents::TriggerAgent do
141 141
     end
142 142
 
143 143
     it "does fine without dots in the path" do
144
-      @event.payload = { :hello => "world" }
145
-      @checker.options[:rules].first[:type] = "field==value"
146
-      @checker.options[:rules].first[:path] = "hello"
147
-      @checker.options[:rules].first[:value] = "world"
144
+      @event.payload = { 'hello' => "world" }
145
+      @checker.options['rules'].first['type'] = "field==value"
146
+      @checker.options['rules'].first['path'] = "hello"
147
+      @checker.options['rules'].first['value'] = "world"
148 148
       lambda {
149 149
         @checker.receive([@event])
150 150
       }.should change { Event.count }.by(1)
151 151
 
152
-      @checker.options[:rules].first[:path] = "foo"
152
+      @checker.options['rules'].first['path'] = "foo"
153 153
       lambda {
154 154
         @checker.receive([@event])
155 155
       }.should_not change { Event.count }
156 156
 
157
-      @checker.options[:rules].first[:value] = "hi"
157
+      @checker.options['rules'].first['value'] = "hi"
158 158
       lambda {
159 159
         @checker.receive([@event])
160 160
       }.should_not change { Event.count }
@@ -163,11 +163,11 @@ describe Agents::TriggerAgent do
163 163
     it "handles multiple events" do
164 164
       event2 = Event.new
165 165
       event2.agent = agents(:bob_weather_agent)
166
-      event2.payload = { :foo => { "bar" => { :baz => "a2b" }}}
166
+      event2.payload = { 'foo' => { 'bar' => { 'baz' => "a2b" }}}
167 167
 
168 168
       event3 = Event.new
169 169
       event3.agent = agents(:bob_weather_agent)
170
-      event3.payload = { :foo => { "bar" => { :baz => "a222b" }}}
170
+      event3.payload = { 'foo' => { 'bar' => { 'baz' => "a222b" }}}
171 171
 
172 172
       lambda {
173 173
         @checker.receive([@event, event2, event3])
@@ -175,19 +175,19 @@ describe Agents::TriggerAgent do
175 175
     end
176 176
 
177 177
     it "handles ANDing rules together" do
178
-      @checker.options[:rules] << {
179
-          :type => "field>=value",
180
-          :value => "4",
181
-          :path => "foo.bing"
178
+      @checker.options['rules'] << {
179
+        'type' => "field>=value",
180
+        'value' => "4",
181
+        'path' => "foo.bing"
182 182
       }
183 183
 
184
-      @event.payload[:foo]["bing"] = "5"
184
+      @event.payload['foo']["bing"] = "5"
185 185
 
186 186
       lambda {
187 187
         @checker.receive([@event])
188 188
       }.should change { Event.count }.by(1)
189 189
 
190
-      @checker.options[:rules].last[:value] = 6
190
+      @checker.options['rules'].last['value'] = 6
191 191
       lambda {
192 192
         @checker.receive([@event])
193 193
       }.should_not change { Event.count }

+ 7 - 7
spec/models/agents/twitter_stream_agent_spec.rb

@@ -53,7 +53,7 @@ describe Agents::TwitterStreamAgent do
53 53
         @agent.memory[:filter_counts] = {:keyword1 => 2, :keyword2 => 3, :keyword3 => 4}
54 54
         @agent.save!
55 55
         @agent.process_tweet('keyword1', {:text => "something", :user => {:name => "Mr. Someone"}})
56
-        @agent.reload.memory[:filter_counts].should == {:keyword1 => 3, :keyword2 => 3}
56
+        @agent.reload.memory[:filter_counts].should == { 'keyword1' => 3, 'keyword2' => 3 }
57 57
       end
58 58
     end
59 59
 
@@ -64,9 +64,9 @@ describe Agents::TwitterStreamAgent do
64 64
         }.should change { @agent.events.count }.by(1)
65 65
 
66 66
         @agent.events.last.payload.should == {
67
-          :filter => 'keyword1',
68
-          :text => "something",
69
-          :user => {:name => "Mr. Someone"}
67
+          'filter' => 'keyword1',
68
+          'text' => "something",
69
+          'user' => { 'name' => "Mr. Someone" }
70 70
         }
71 71
       end
72 72
 
@@ -79,9 +79,9 @@ describe Agents::TwitterStreamAgent do
79 79
         }.should change { @agent.events.count }.by(1)
80 80
 
81 81
         @agent.events.last.payload.should == {
82
-          :filter => 'keyword1-1',
83
-          :text => "something",
84
-          :user => {:name => "Mr. Someone"}
82
+          'filter' => 'keyword1-1',
83
+          'text' => "something",
84
+          'user' => { 'name' => "Mr. Someone" }
85 85
         }
86 86
       end
87 87
     end

+ 31 - 0
spec/models/agents/webhook_agent_spec.rb

@@ -0,0 +1,31 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::WebhookAgent do
4
+  let(:agent) do
5
+    _agent = Agents::WebhookAgent.new(:name => 'webhook',
6
+                                      :options => { 'secret' => 'foobar', 'payload_path' => 'payload' })
7
+    _agent.user = users(:bob)
8
+    _agent.save!
9
+    _agent
10
+  end
11
+  let(:payload) { {'some' => 'info'} }
12
+
13
+  describe 'receive_webhook' do
14
+    it 'should create event if secret matches' do
15
+      out = nil
16
+      lambda {
17
+        out = agent.receive_webhook('secret' => 'foobar', 'payload' => payload)
18
+      }.should change { Event.count }.by(1)
19
+      out.should eq(['Event Created', 201])
20
+      Event.last.payload.should eq(payload)
21
+    end
22
+
23
+    it 'should not create event if secrets dont match' do
24
+      out = nil
25
+      lambda {
26
+        out = agent.receive_webhook('secret' => 'bazbat', 'payload' => payload)
27
+      }.should change { Event.count }.by(0)
28
+      out.should eq(['Not Authorized', 401])
29
+    end
30
+  end
31
+end

+ 9 - 5
spec/models/agents/website_agent_spec.rb

@@ -44,18 +44,22 @@ describe Agents::WebsiteAgent do
44 44
 
45 45
   describe '#working?' do
46 46
     it 'checks if events have been received within the expected receive period' do
47
+      stubbed_time = Time.now
48
+      stub(Time).now { stubbed_time }
49
+
47 50
       @checker.should_not be_working # No events created
48 51
       @checker.check
49 52
       @checker.reload.should be_working # Just created events
50 53
 
51 54
       @checker.error "oh no!"
52
-      @checker.reload.should_not be_working # The most recent log is an error
55
+      @checker.reload.should_not be_working # There is a recent error
53 56
 
54
-      @checker.log "ok now"
55
-      @checker.reload.should be_working # The most recent log is no longer an error
57
+      stubbed_time = 20.minutes.from_now
58
+      @checker.events.delete_all
59
+      @checker.check
60
+      @checker.reload.should be_working # There is a newer event now
56 61
 
57
-      two_days_from_now = 2.days.from_now
58
-      stub(Time).now { two_days_from_now }
62
+      stubbed_time = 2.days.from_now
59 63
       @checker.reload.should_not be_working # Two days have passed without a new event having been created
60 64
     end
61 65
   end